eslint-plugin-primer-react 5.4.0 → 6.0.0-rc.ad9e155

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-primer-react",
3
- "version": "5.4.0",
3
+ "version": "6.0.0-rc.ad9e155",
4
4
  "description": "ESLint rules for Primer React",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -33,7 +33,9 @@
33
33
  "eslint-plugin-jsx-a11y": "^6.7.1",
34
34
  "eslint-traverse": "^1.0.0",
35
35
  "lodash": "^4.17.21",
36
- "styled-system": "^5.1.5"
36
+ "styled-system": "^5.1.5",
37
+ "@typescript-eslint/utils": "7.16.0",
38
+ "typescript": "^5.5.3"
37
39
  },
38
40
  "devDependencies": {
39
41
  "@changesets/changelog-github": "^0.5.0",
@@ -44,7 +46,9 @@
44
46
  "eslint-plugin-prettier": "^5.0.1",
45
47
  "jest": "^29.7.0",
46
48
  "markdownlint-cli2": "^0.13.0",
47
- "markdownlint-cli2-formatter-pretty": "^0.0.6"
49
+ "markdownlint-cli2-formatter-pretty": "^0.0.6",
50
+ "@typescript-eslint/rule-tester": "7.16.0",
51
+ "@types/jest": "^29.5.12"
48
52
  },
49
53
  "prettier": "@github/prettier-config"
50
54
  }
@@ -18,6 +18,7 @@ module.exports = {
18
18
  'primer-react/no-deprecated-props': 'warn',
19
19
  'primer-react/a11y-remove-disable-tooltip': 'error',
20
20
  'primer-react/a11y-use-next-tooltip': 'error',
21
+ 'primer-react/no-unnecessary-components': 'error',
21
22
  },
22
23
  settings: {
23
24
  github: {
package/src/index.js CHANGED
@@ -10,6 +10,8 @@ module.exports = {
10
10
  'a11y-link-in-text-block': require('./rules/a11y-link-in-text-block'),
11
11
  'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'),
12
12
  'a11y-use-next-tooltip': require('./rules/a11y-use-next-tooltip'),
13
+ 'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'),
14
+ 'primer-react/no-unnecessary-components': require('./rules/no-unnecessary-components'),
13
15
  },
14
16
  configs: {
15
17
  recommended: require('./configs/recommended'),
@@ -0,0 +1 @@
1
+ // https://typescript-eslint.io/packages/rule-tester/#type-aware-testing
@@ -0,0 +1 @@
1
+ // https://typescript-eslint.io/packages/rule-tester/#type-aware-testing
@@ -0,0 +1,7 @@
1
+ // https://typescript-eslint.io/packages/rule-tester/#type-aware-testing
2
+ {
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "include": ["file.ts", "File.tsx"]
7
+ }
@@ -0,0 +1,143 @@
1
+ // @ts-check
2
+
3
+ const {RuleTester} = require('@typescript-eslint/rule-tester')
4
+
5
+ const path = require('node:path')
6
+ const rule = require('../no-unnecessary-components')
7
+ const {components} = require('../no-unnecessary-components')
8
+
9
+ const prcImport = 'import React from "react"; import {Box, Text} from "@primer/react";'
10
+ const brandImport = 'import React from "react"; import {Box, Text} from "@primer/brand";'
11
+
12
+ /** @param {string} content */
13
+ const jsx = content => `export const Component = () => <>${content}</>`
14
+
15
+ const sxObjectDeclaration = `const props = {sx: {color: "red"}};`
16
+ const asObjectDeclaration = `const props = {as: "table"};`
17
+ const stringRecordDeclaration = `const props: Record<string, any> = {};`
18
+ const testIdObjectDeclaration = `const props = {'data-testid': 'xyz'};`
19
+ const componentDeclaration = `const OtherComponent = ({children}: {children: React.ReactNode}) => <>{children}</>;`
20
+ const asConstDeclaration = `const as = "p";`
21
+
22
+ const ruleTester = new RuleTester({
23
+ parser: '@typescript-eslint/parser',
24
+ parserOptions: {
25
+ tsconfigRootDir: path.resolve(__dirname, 'fixtures'),
26
+ project: path.resolve(__dirname, 'fixtures', 'tsconfig.json'),
27
+ },
28
+ defaultFilenames: {
29
+ ts: 'file.ts',
30
+ tsx: 'File.tsx',
31
+ },
32
+ })
33
+
34
+ jest.retryTimes(0, {logErrorsBeforeRetry: true})
35
+
36
+ const filename = 'File.tsx'
37
+
38
+ ruleTester.run('unnecessary-components', rule, {
39
+ valid: [
40
+ {name: 'Unrelated JSX', code: jsx('<span>Hello World</span>'), filename},
41
+ ...Object.keys(components).flatMap(component => [
42
+ {
43
+ name: `Non-PRC ${component}`,
44
+ code: `${brandImport}${jsx(`<${component}>Hello World</${component}>`)}`,
45
+ filename,
46
+ },
47
+ {
48
+ name: `${component} with sx prop`,
49
+ code: `${prcImport}${jsx(`<${component} sx={{color: "red"}}>Hello World</${component}>`)}`,
50
+ filename,
51
+ },
52
+ {
53
+ name: `${component} with any styled-system prop`,
54
+ code: `${prcImport}${jsx(`<${component} flex="row">Hello World</${component}>`)}`,
55
+ filename,
56
+ },
57
+ {
58
+ name: `${component} with spread sx prop`,
59
+ code: `${prcImport}${sxObjectDeclaration}${jsx(`<${component} {...props}>Hello World</${component}>`)}`,
60
+ filename,
61
+ },
62
+ {
63
+ name: `${component} with string index spread props`,
64
+ code: `${prcImport}${stringRecordDeclaration}${jsx(`<${component} {...props}>Hello World</${component}>`)}`,
65
+ filename,
66
+ },
67
+ ]),
68
+ ],
69
+ invalid: Object.entries(components).flatMap(([component, {messageId, replacement}]) => [
70
+ {
71
+ name: `${component} without any styled-system props`,
72
+ code: `${prcImport}${jsx(`<${component}>Hello World</${component}>`)}`,
73
+ output: `${prcImport}${jsx(`<${replacement}>Hello World</${replacement}>`)}`,
74
+ errors: [{messageId}],
75
+ filename,
76
+ },
77
+ {
78
+ name: `Self-closing ${component} without any styled-system props`,
79
+ code: `${prcImport}${jsx(`<${component} />`)}`,
80
+ output: `${prcImport}${jsx(`<${replacement} />`)}`,
81
+ errors: [{messageId}],
82
+ filename,
83
+ },
84
+ {
85
+ name: `${component} with spread props without sx`,
86
+ code: `${prcImport}${testIdObjectDeclaration}${jsx(`<${component} {...props}>Hello World</${component}>`)}`,
87
+ output: `${prcImport}${testIdObjectDeclaration}${jsx(`<${replacement} {...props}>Hello World</${replacement}>`)}`,
88
+ errors: [{messageId}],
89
+ filename,
90
+ },
91
+ {
92
+ name: `${component} with string element 'as' prop`,
93
+ code: `${prcImport}${jsx(`<${component} as="code">Hello world</${component}>`)}`,
94
+ // There is extra whitespace here we don't worry about since formatters would get rid of it
95
+ output: `${prcImport}${jsx(`<code >Hello world</code>`)}`,
96
+ errors: [{messageId}],
97
+ filename,
98
+ },
99
+ {
100
+ name: `${component} with single-character 'as' prop`,
101
+ code: `${prcImport}${jsx(`<${component} as="p">Hello world</${component}>`)}`,
102
+ output: `${prcImport}${jsx(`<p >Hello world</p>`)}`,
103
+ errors: [{messageId}],
104
+ filename,
105
+ },
106
+ {
107
+ name: `${component} with string element 'as' prop surrounded by unnecessary braces`,
108
+ code: `${prcImport}${jsx(`<${component} as={"code"}>Hello world</${component}>`)}`,
109
+ output: `${prcImport}${jsx(`<code >Hello world</code>`)}`,
110
+ errors: [{messageId}],
111
+ filename,
112
+ },
113
+ {
114
+ name: `${component} with component reference 'as' prop`,
115
+ code: `${prcImport}${componentDeclaration}${jsx(`<${component} as={OtherComponent}>Hello world</${component}>`)}`,
116
+ output: `${prcImport}${componentDeclaration}${jsx(`<OtherComponent >Hello world</OtherComponent>`)}`,
117
+ errors: [{messageId}],
118
+ filename,
119
+ },
120
+ {
121
+ name: `${component} with spread 'as' prop`,
122
+ code: `${prcImport}${asObjectDeclaration}${jsx(`<${component} {...props}>Hello world</${component}>`)}`,
123
+ output: null,
124
+ errors: [{messageId}],
125
+ filename,
126
+ },
127
+ {
128
+ name: `${component} with unusable lowercase reference 'as' prop`,
129
+ code: `${prcImport}${asConstDeclaration}${jsx(`<${component} as={as}>Hello world</${component}>`)}`,
130
+ output: null,
131
+ errors: [{messageId}],
132
+ filename,
133
+ },
134
+ {
135
+ name: `Non-PRC ${component} when \`skipImportCheck\` is enabled`,
136
+ code: `${brandImport}${jsx(`<${component}>Hello World</${component}>`)}`,
137
+ output: `${brandImport}${jsx(`<${replacement}>Hello World</${replacement}>`)}`,
138
+ filename,
139
+ errors: [{messageId}],
140
+ options: [{skipImportCheck: true}],
141
+ },
142
+ ]),
143
+ })
@@ -0,0 +1,66 @@
1
+ 'use strict'
2
+
3
+ const {RuleTester} = require('eslint')
4
+ const rule = require('../use-deprecated-from-deprecated')
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('use-deprecated-from-deprecated', rule, {
17
+ valid: [],
18
+ invalid: [
19
+ // Single deprecated import
20
+ {
21
+ code: `import {Tooltip} from '@primer/react'`,
22
+ output: `import {Tooltip} from '@primer/react/deprecated'`,
23
+ errors: ['Import deprecated components from @primer/react/deprecated'],
24
+ },
25
+
26
+ // Single deprecated import with existing deprecated entrypoint
27
+ {
28
+ code: `import {Tooltip} from '@primer/react'
29
+ import {Dialog} from '@primer/react/deprecated'`,
30
+ output: `\nimport {Dialog, Tooltip} from '@primer/react/deprecated'`,
31
+ errors: ['Import deprecated components from @primer/react/deprecated'],
32
+ },
33
+
34
+ // Multiple deprecated imports
35
+ {
36
+ code: `import {Dialog, Tooltip} from '@primer/react'`,
37
+ output: `import {Dialog, Tooltip} from '@primer/react/deprecated'`,
38
+ errors: ['Import deprecated components from @primer/react/deprecated'],
39
+ },
40
+
41
+ // Mixed deprecated and non-deprecated imports
42
+ {
43
+ code: `import {Button, Tooltip} from '@primer/react'`,
44
+ output: `import {Button, } from '@primer/react'
45
+ import {Tooltip} from '@primer/react/deprecated'`,
46
+ errors: ['Import deprecated components from @primer/react/deprecated'],
47
+ },
48
+
49
+ // Mixed deprecated and non-deprecated imports with existing deprecated
50
+ {
51
+ code: `import {Button, Tooltip} from '@primer/react'
52
+ import {Dialog} from '@primer/react/deprecated'`,
53
+ output: `import {Button, } from '@primer/react'
54
+ import {Dialog, Tooltip} from '@primer/react/deprecated'`,
55
+ errors: ['Import deprecated components from @primer/react/deprecated'],
56
+ },
57
+
58
+ // Multiple mixed deprecated and non-deprecated imports
59
+ {
60
+ code: `import {Button, Dialog, Tooltip} from '@primer/react'`,
61
+ output: `import {Button, } from '@primer/react'
62
+ import {Dialog, Tooltip} from '@primer/react/deprecated'`,
63
+ errors: ['Import deprecated components from @primer/react/deprecated'],
64
+ },
65
+ ],
66
+ })
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+
3
+ const {ESLintUtils} = require('@typescript-eslint/utils')
4
+ const {IndexKind} = require('typescript')
5
+ const {pick: pickStyledSystemProps} = require('@styled-system/props')
6
+ const {isPrimerComponent} = require('../utils/is-primer-component')
7
+
8
+ /** @typedef {import('@typescript-eslint/types').TSESTree.JSXAttribute} JSXAttribute */
9
+
10
+ const components = {
11
+ Box: {
12
+ replacement: 'div',
13
+ messageId: 'unecessaryBox',
14
+ message: 'Prefer plain HTML elements over `Box` when not using `sx` for styling.',
15
+ },
16
+ Text: {
17
+ replacement: 'span',
18
+ messageId: 'unecessarySpan',
19
+ message: 'Prefer plain HTML elements over `Text` when not using `sx` for styling.',
20
+ },
21
+ }
22
+
23
+ const elementNameRegex = /^[a-z]\w*$/
24
+ const componentNameRegex = /^[A-Z][\w._]*$/
25
+
26
+ /** @param {string} propName */
27
+ const isStyledSystemProp = propName => propName in pickStyledSystemProps({[propName]: propName})
28
+
29
+ const rule = ESLintUtils.RuleCreator.withoutDocs({
30
+ meta: {
31
+ docs: {
32
+ description:
33
+ '`Box` and `Text` should only be used to provide access to the `sx` styling system and have a performance cost. If `sx` props are not being used, prefer `div` and `span` instead.',
34
+ },
35
+ messages: {
36
+ [components.Box.messageId]: components.Box.message,
37
+ [components.Text.messageId]: components.Text.message,
38
+ },
39
+ type: 'problem',
40
+ schema: [
41
+ {
42
+ type: 'object',
43
+ properties: {
44
+ skipImportCheck: {
45
+ type: 'boolean',
46
+ },
47
+ },
48
+ additionalProperties: false,
49
+ },
50
+ ],
51
+ fixable: 'code',
52
+ },
53
+ defaultOptions: [{skipImportCheck: false}],
54
+ create(context) {
55
+ return {
56
+ JSXElement({openingElement, closingElement}) {
57
+ const {name, attributes} = openingElement
58
+
59
+ // Ensure this is one of the components we are looking for. Note this doesn't account for import aliases; this
60
+ // is intentional to avoid having to do the scope tree traversal for every component of every name, which would
61
+ // be needlessly expensive. We just ignore aliased imports.
62
+ if (name.type !== 'JSXIdentifier' || !(name.name in components)) return
63
+ const componentConfig = components[/** @type {keyof typeof components} */ (name.name)]
64
+
65
+ // Only continue if the variable declaration is an import from @primer/react. Otherwise it could, for example,
66
+ // be an import from @primer/brand, which would be valid without sx.
67
+ const skipImportCheck = context.options[0]?.skipImportCheck
68
+ const isPrimer = skipImportCheck || isPrimerComponent(name, context.sourceCode.getScope(openingElement))
69
+ if (!isPrimer) return
70
+
71
+ // Validate the attributes and ensure an `sx` prop is present or spreaded in
72
+ /** @type {typeof attributes[number] | undefined | null} */
73
+ let asProp = undefined
74
+ for (const attribute of attributes) {
75
+ // If there is a spread type, check if the type of the spreaded value has an `sx` property
76
+ if (attribute.type === 'JSXSpreadAttribute') {
77
+ const services = ESLintUtils.getParserServices(context)
78
+ const typeChecker = services.program.getTypeChecker()
79
+
80
+ const spreadType = services.getTypeAtLocation(attribute.argument)
81
+ if (typeChecker.getPropertyOfType(spreadType, 'sx') !== undefined) return
82
+
83
+ // Check if the spread type has a string index signature - this could hide an `sx` property
84
+ if (typeChecker.getIndexTypeOfType(spreadType, IndexKind.String) !== undefined) return
85
+
86
+ // If there is an `as` inside the spread object, we can't autofix reliably
87
+ if (typeChecker.getPropertyOfType(spreadType, 'as') !== undefined) asProp = null
88
+
89
+ continue
90
+ }
91
+
92
+ // Has sx prop, so should keep using this component
93
+ if (
94
+ attribute.name.type === 'JSXIdentifier' &&
95
+ (attribute.name.name === 'sx' || isStyledSystemProp(attribute.name.name))
96
+ )
97
+ return
98
+
99
+ // If there is an `as` prop we will need to account for that when autofixing
100
+ if (attribute.name.type === 'JSXIdentifier' && attribute.name.name === 'as') asProp = attribute
101
+ }
102
+
103
+ // Determine a replacement component name accounting for the `as` prop if present
104
+ /** @type {string | null} */
105
+ let replacement = componentConfig.replacement
106
+ if (asProp === null) {
107
+ // {...{as: 'something-unusable'}}
108
+ replacement = null
109
+ } else if (asProp?.type === 'JSXAttribute') {
110
+ // as={ComponentReference}
111
+ if (asProp.value?.type === 'JSXExpressionContainer' && asProp.value.expression.type === 'Identifier') {
112
+ // can't just use expression.name here because we want the whole expression if it's A.B
113
+ const expressionStr = context.sourceCode.getText(asProp.value.expression)
114
+ replacement = componentNameRegex.test(expressionStr) ? expressionStr : null
115
+ }
116
+ // as={'tagName'} (surprisingly common, we really should enable `react/jsx-curly-brace-presence`)
117
+ else if (
118
+ asProp.value?.type === 'JSXExpressionContainer' &&
119
+ asProp.value.expression.type === 'Literal' &&
120
+ typeof asProp.value.expression.value === 'string' &&
121
+ elementNameRegex.test(asProp.value.expression.value)
122
+ ) {
123
+ replacement = asProp.value.expression.value
124
+ }
125
+ // as="tagName"
126
+ else if (
127
+ asProp.value?.type === 'Literal' &&
128
+ typeof asProp.value.value === 'string' &&
129
+ elementNameRegex.test(asProp.value.value)
130
+ ) {
131
+ replacement = asProp.value.value
132
+ }
133
+ // too complex to autofix
134
+ else {
135
+ replacement = null
136
+ }
137
+ }
138
+
139
+ context.report({
140
+ node: name,
141
+ messageId: componentConfig.messageId,
142
+ fix: replacement
143
+ ? function* (fixer) {
144
+ yield fixer.replaceText(name, replacement)
145
+ if (closingElement) yield fixer.replaceText(closingElement.name, replacement)
146
+ if (asProp) yield fixer.remove(asProp)
147
+ }
148
+ : undefined,
149
+ })
150
+ },
151
+ }
152
+ },
153
+ })
154
+
155
+ module.exports = {...rule, components}
@@ -0,0 +1,130 @@
1
+ 'use strict'
2
+
3
+ const url = require('../url')
4
+
5
+ const components = [
6
+ {
7
+ identifier: 'Dialog',
8
+ entrypoint: '@primer/react',
9
+ },
10
+ {
11
+ identifier: 'Octicon',
12
+ entrypoint: '@primer/react',
13
+ },
14
+ {
15
+ identifier: 'Pagehead',
16
+ entrypoint: '@primer/react',
17
+ },
18
+ {
19
+ identifier: 'TabNav',
20
+ entrypoint: '@primer/react',
21
+ },
22
+ {
23
+ identifier: 'Tooltip',
24
+ entrypoint: '@primer/react',
25
+ },
26
+ ]
27
+
28
+ const entrypoints = new Map()
29
+
30
+ for (const component of components) {
31
+ if (!entrypoints.has(component.entrypoint)) {
32
+ entrypoints.set(component.entrypoint, new Set())
33
+ }
34
+ entrypoints.get(component.entrypoint).add(component.identifier)
35
+ }
36
+
37
+ /**
38
+ * @type {import('eslint').Rule.RuleModule}
39
+ */
40
+ module.exports = {
41
+ meta: {
42
+ type: 'problem',
43
+ docs: {
44
+ description: 'Use deprecated components from the `@primer/react/deprecated` entrypoint',
45
+ recommended: true,
46
+ url: url(module),
47
+ },
48
+ fixable: true,
49
+ schema: [],
50
+ },
51
+ create(context) {
52
+ const sourceCode = context.getSourceCode()
53
+
54
+ return {
55
+ ImportDeclaration(node) {
56
+ if (!entrypoints.has(node.source.value)) {
57
+ return
58
+ }
59
+
60
+ const entrypoint = entrypoints.get(node.source.value)
61
+ const deprecated = node.specifiers.filter(specifier => {
62
+ return entrypoint.has(specifier.imported.name)
63
+ })
64
+
65
+ if (deprecated.length === 0) {
66
+ return
67
+ }
68
+
69
+ const deprecatedEntrypoint = node.parent.body.find(node => {
70
+ if (node.type !== 'ImportDeclaration') {
71
+ return false
72
+ }
73
+
74
+ return node.source.value === '@primer/react/deprecated'
75
+ })
76
+
77
+ // All imports are deprecated
78
+ if (deprecated.length === node.specifiers.length) {
79
+ context.report({
80
+ node,
81
+ message: 'Import deprecated components from @primer/react/deprecated',
82
+ *fix(fixer) {
83
+ if (deprecatedEntrypoint) {
84
+ const lastSpecifier = deprecatedEntrypoint.specifiers[deprecatedEntrypoint.specifiers.length - 1]
85
+
86
+ yield fixer.remove(node)
87
+ yield fixer.insertTextAfter(
88
+ lastSpecifier,
89
+ `, ${node.specifiers.map(specifier => specifier.imported.name).join(', ')}`,
90
+ )
91
+ } else {
92
+ yield fixer.replaceText(node.source, `'@primer/react/deprecated'`)
93
+ }
94
+ },
95
+ })
96
+ } else {
97
+ // There is a mix of deprecated and non-deprecated imports
98
+ context.report({
99
+ node,
100
+ message: 'Import deprecated components from @primer/react/deprecated',
101
+ *fix(fixer) {
102
+ for (const specifier of deprecated) {
103
+ yield fixer.remove(specifier)
104
+ const comma = sourceCode.getTokenAfter(specifier)
105
+ if (comma.value === ',') {
106
+ yield fixer.remove(comma)
107
+ }
108
+ }
109
+
110
+ if (deprecatedEntrypoint) {
111
+ const lastSpecifier = deprecatedEntrypoint.specifiers[deprecatedEntrypoint.specifiers.length - 1]
112
+ yield fixer.insertTextAfter(
113
+ lastSpecifier,
114
+ `, ${deprecated.map(specifier => specifier.imported.name).join(', ')}`,
115
+ )
116
+ } else {
117
+ yield fixer.insertTextAfter(
118
+ node,
119
+ `\nimport {${deprecated
120
+ .map(specifier => specifier.imported.name)
121
+ .join(', ')}} from '@primer/react/deprecated'`,
122
+ )
123
+ }
124
+ },
125
+ })
126
+ }
127
+ },
128
+ }
129
+ },
130
+ }
@@ -1,5 +1,6 @@
1
1
  const {isImportedFrom} = require('./is-imported-from')
2
2
 
3
+ /** @returns {boolean} */
3
4
  function isPrimerComponent(name, scope) {
4
5
  let identifier
5
6