diff --git a/.changeset/kind-schools-retire.md b/.changeset/kind-schools-retire.md new file mode 100644 index 00000000..5641af1a --- /dev/null +++ b/.changeset/kind-schools-retire.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-primer-react': major +--- + +[Breaking] Adds `no-unnecessary-components` lint rule and enables it by default. This may raise new (typically autofixable) lint errors in existing codebases. diff --git a/docs/rules/no-unnecessary-components.md b/docs/rules/no-unnecessary-components.md new file mode 100644 index 00000000..0062b1a7 --- /dev/null +++ b/docs/rules/no-unnecessary-components.md @@ -0,0 +1,69 @@ +# Disallow unnecessary use of `Box` and `Text` components (no-unnecessary-components) + +🔧 The `--fix` option on the [ESLint CLI](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can +automatically fix some of the problems reported by this rule. + +## Rule details + +The [`Box`](https://primer.style/components/box) and [`Text`](https://primer.style/components/text) +Primer React components are utilities that exist solely to provide access to `sx` or styled-system +props. + +If these props are not being used, plain HTML element provide better performance, simpler code, +and improved linting support. + +This rule is auto-fixable in nearly all cases. Autofixing respects the presence of an `as` prop. + +👎 Examples of **incorrect** code for this rule: + +```jsx +/* eslint primer-react/no-unnecessary-components: "error" */ +import {Box, Text} from '@primer/react' + +Content +Content +Content + +Content +Content +Content +``` + +👍 Examples of **correct** code for this rule: + +```jsx +/* eslint primer-react/no-system-props: "error" */ +import {Box, Text} from '@primer/react' + +// Prefer plain HTML elements (autofixable) +
Content
+
Content
+
Content
+ +Content +Content +

Content

+ +// sx props are allowed +Content +Content + +// styled-system props are allowed +Content +Content +``` + +```jsx +/* eslint primer-react/no-system-props: ["error", {skipImportCheck: false}] */ +import {Box, Text} from '@primer/brand' + +// Other components with the same name are allowed +Content +Content +``` + +## Options + +- `skipImportCheck` (default: `false`) + + By default, the rule will only check for incorrect uses of `Box` and `Text` components that are are imported from `@primer/react`. You can disable this behavior (checking all components with these names regardless of import source) by setting `skipImportCheck` to `true`. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..ebb0133b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,7 @@ +// @ts-check + +/** @type {import('jest').Config} **/ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/__tests__/*.test.js'], +} diff --git a/package-lock.json b/package-lock.json index ffb12286..31c49488 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,17 +10,21 @@ "license": "MIT", "dependencies": { "@styled-system/props": "^5.1.5", + "@typescript-eslint/utils": "7.16.0", "eslint-plugin-github": "^5.0.1", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-traverse": "^1.0.0", "lodash": "^4.17.21", - "styled-system": "^5.1.5" + "styled-system": "^5.1.5", + "typescript": "^5.5.3" }, "devDependencies": { "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.16.0", "@github/markdownlint-github": "^0.6.0", "@github/prettier-config": "0.0.6", + "@types/jest": "^29.5.12", + "@typescript-eslint/rule-tester": "7.16.0", "eslint": "^8.42.0", "eslint-plugin-prettier": "^5.0.1", "jest": "^29.7.0", @@ -2120,10 +2124,22 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "29.5.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", + "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -2199,6 +2215,31 @@ } } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", + "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -2240,6 +2281,132 @@ } } }, + "node_modules/@typescript-eslint/rule-tester": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/rule-tester/-/rule-tester-7.16.0.tgz", + "integrity": "sha512-MLDeDEY8BVZiWkIhtMnEkhiwH7CXDOLKDnHntFZp//WHYdso+1gS/8F60+oTb+Xjw4LkWz4D8sJ4js3ZxMoctA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.16.0", + "@typescript-eslint/utils": "7.16.0", + "ajv": "^6.12.6", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "4.6.2", + "semver": "^7.6.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@eslint/eslintrc": ">=2", + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz", + "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz", + "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz", + "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.16.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/rule-tester/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/rule-tester/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/rule-tester/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz", @@ -2282,6 +2449,43 @@ } } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", + "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/types": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz", @@ -2358,20 +2562,18 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", - "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz", + "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==", + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/typescript-estree": "7.1.1", - "semver": "^7.5.4" + "@typescript-eslint/scope-manager": "7.16.0", + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/typescript-estree": "7.16.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -2381,13 +2583,110 @@ "eslint": "^8.56.0" } }, - "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz", + "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==", + "license": "MIT", "dependencies": { - "lru-cache": "^6.0.0" + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz", + "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz", + "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==", + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz", + "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.16.0", + "eslint-visitor-keys": "^3.4.3" }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -7721,9 +8020,10 @@ "dev": true }, "node_modules/ts-api-utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", - "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "license": "MIT", "engines": { "node": ">=16" }, @@ -7857,10 +8157,10 @@ } }, "node_modules/typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", - "peer": true, + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9900,6 +10200,16 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "29.5.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", + "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "dev": true, + "requires": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -9963,6 +10273,20 @@ "ts-api-utils": "^1.0.1" }, "dependencies": { + "@typescript-eslint/utils": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", + "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "requires": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", + "semver": "^7.5.4" + } + }, "semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -9985,6 +10309,78 @@ "debug": "^4.3.4" } }, + "@typescript-eslint/rule-tester": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/rule-tester/-/rule-tester-7.16.0.tgz", + "integrity": "sha512-MLDeDEY8BVZiWkIhtMnEkhiwH7CXDOLKDnHntFZp//WHYdso+1gS/8F60+oTb+Xjw4LkWz4D8sJ4js3ZxMoctA==", + "dev": true, + "requires": { + "@typescript-eslint/typescript-estree": "7.16.0", + "@typescript-eslint/utils": "7.16.0", + "ajv": "^6.12.6", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "4.6.2", + "semver": "^7.6.0" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz", + "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz", + "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz", + "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.16.0", + "eslint-visitor-keys": "^3.4.3" + } + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true + } + } + }, "@typescript-eslint/scope-manager": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz", @@ -10003,6 +10399,27 @@ "@typescript-eslint/utils": "7.1.1", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" + }, + "dependencies": { + "@typescript-eslint/utils": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", + "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "requires": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", + "semver": "^7.5.4" + } + }, + "semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" + } } }, "@typescript-eslint/types": { @@ -10052,26 +10469,74 @@ } }, "@typescript-eslint/utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", - "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz", + "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==", "requires": { "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/typescript-estree": "7.1.1", - "semver": "^7.5.4" + "@typescript-eslint/scope-manager": "7.16.0", + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/typescript-estree": "7.16.0" }, "dependencies": { - "semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "@typescript-eslint/scope-manager": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz", + "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==", "requires": { - "lru-cache": "^6.0.0" + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0" + } + }, + "@typescript-eslint/types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz", + "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==" + }, + "@typescript-eslint/typescript-estree": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz", + "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==", + "requires": { + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz", + "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==", + "requires": { + "@typescript-eslint/types": "7.16.0", + "eslint-visitor-keys": "^3.4.3" + } + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" } + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" } } }, @@ -13887,9 +14352,9 @@ "dev": true }, "ts-api-utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", - "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", "requires": {} }, "tsconfig-paths": { @@ -13984,10 +14449,9 @@ } }, "typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", - "peer": true + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==" }, "uc.micro": { "version": "2.1.0", diff --git a/package.json b/package.json index 7d43fca1..f8afeeb1 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-traverse": "^1.0.0", "lodash": "^4.17.21", - "styled-system": "^5.1.5" + "styled-system": "^5.1.5", + "@typescript-eslint/utils": "7.16.0", + "typescript": "^5.5.3" }, "devDependencies": { "@changesets/changelog-github": "^0.5.0", @@ -44,7 +46,9 @@ "eslint-plugin-prettier": "^5.0.1", "jest": "^29.7.0", "markdownlint-cli2": "^0.13.0", - "markdownlint-cli2-formatter-pretty": "^0.0.6" + "markdownlint-cli2-formatter-pretty": "^0.0.6", + "@typescript-eslint/rule-tester": "7.16.0", + "@types/jest": "^29.5.12" }, "prettier": "@github/prettier-config" } diff --git a/src/configs/recommended.js b/src/configs/recommended.js index 916be1fa..b7cc9481 100644 --- a/src/configs/recommended.js +++ b/src/configs/recommended.js @@ -18,6 +18,7 @@ module.exports = { 'primer-react/no-deprecated-props': 'warn', 'primer-react/a11y-remove-disable-tooltip': 'error', 'primer-react/a11y-use-next-tooltip': 'error', + 'primer-react/no-unnecessary-components': 'error', }, settings: { github: { diff --git a/src/index.js b/src/index.js index d9ce57df..5e4a328f 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ module.exports = { 'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'), 'a11y-use-next-tooltip': require('./rules/a11y-use-next-tooltip'), 'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'), + 'primer-react/no-unnecessary-components': require('./rules/no-unnecessary-components'), }, configs: { recommended: require('./configs/recommended'), diff --git a/src/rules/__tests__/fixtures/File.tsx b/src/rules/__tests__/fixtures/File.tsx new file mode 100644 index 00000000..275b3a6b --- /dev/null +++ b/src/rules/__tests__/fixtures/File.tsx @@ -0,0 +1 @@ +// https://typescript-eslint.io/packages/rule-tester/#type-aware-testing diff --git a/src/rules/__tests__/fixtures/file.ts b/src/rules/__tests__/fixtures/file.ts new file mode 100644 index 00000000..275b3a6b --- /dev/null +++ b/src/rules/__tests__/fixtures/file.ts @@ -0,0 +1 @@ +// https://typescript-eslint.io/packages/rule-tester/#type-aware-testing diff --git a/src/rules/__tests__/fixtures/tsconfig.json b/src/rules/__tests__/fixtures/tsconfig.json new file mode 100644 index 00000000..d5991f05 --- /dev/null +++ b/src/rules/__tests__/fixtures/tsconfig.json @@ -0,0 +1,7 @@ +// https://typescript-eslint.io/packages/rule-tester/#type-aware-testing +{ + "compilerOptions": { + "strict": true + }, + "include": ["file.ts", "File.tsx"] +} diff --git a/src/rules/__tests__/no-unnecessary-components.test.js b/src/rules/__tests__/no-unnecessary-components.test.js new file mode 100644 index 00000000..67e327ff --- /dev/null +++ b/src/rules/__tests__/no-unnecessary-components.test.js @@ -0,0 +1,143 @@ +// @ts-check + +const {RuleTester} = require('@typescript-eslint/rule-tester') + +const path = require('node:path') +const rule = require('../no-unnecessary-components') +const {components} = require('../no-unnecessary-components') + +const prcImport = 'import React from "react"; import {Box, Text} from "@primer/react";' +const brandImport = 'import React from "react"; import {Box, Text} from "@primer/brand";' + +/** @param {string} content */ +const jsx = content => `export const Component = () => <>${content}` + +const sxObjectDeclaration = `const props = {sx: {color: "red"}};` +const asObjectDeclaration = `const props = {as: "table"};` +const stringRecordDeclaration = `const props: Record = {};` +const testIdObjectDeclaration = `const props = {'data-testid': 'xyz'};` +const componentDeclaration = `const OtherComponent = ({children}: {children: React.ReactNode}) => <>{children};` +const asConstDeclaration = `const as = "p";` + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: path.resolve(__dirname, 'fixtures'), + project: path.resolve(__dirname, 'fixtures', 'tsconfig.json'), + }, + defaultFilenames: { + ts: 'file.ts', + tsx: 'File.tsx', + }, +}) + +jest.retryTimes(0, {logErrorsBeforeRetry: true}) + +const filename = 'File.tsx' + +ruleTester.run('unnecessary-components', rule, { + valid: [ + {name: 'Unrelated JSX', code: jsx('Hello World'), filename}, + ...Object.keys(components).flatMap(component => [ + { + name: `Non-PRC ${component}`, + code: `${brandImport}${jsx(`<${component}>Hello World`)}`, + filename, + }, + { + name: `${component} with sx prop`, + code: `${prcImport}${jsx(`<${component} sx={{color: "red"}}>Hello World`)}`, + filename, + }, + { + name: `${component} with any styled-system prop`, + code: `${prcImport}${jsx(`<${component} flex="row">Hello World`)}`, + filename, + }, + { + name: `${component} with spread sx prop`, + code: `${prcImport}${sxObjectDeclaration}${jsx(`<${component} {...props}>Hello World`)}`, + filename, + }, + { + name: `${component} with string index spread props`, + code: `${prcImport}${stringRecordDeclaration}${jsx(`<${component} {...props}>Hello World`)}`, + filename, + }, + ]), + ], + invalid: Object.entries(components).flatMap(([component, {messageId, replacement}]) => [ + { + name: `${component} without any styled-system props`, + code: `${prcImport}${jsx(`<${component}>Hello World`)}`, + output: `${prcImport}${jsx(`<${replacement}>Hello World`)}`, + errors: [{messageId}], + filename, + }, + { + name: `Self-closing ${component} without any styled-system props`, + code: `${prcImport}${jsx(`<${component} />`)}`, + output: `${prcImport}${jsx(`<${replacement} />`)}`, + errors: [{messageId}], + filename, + }, + { + name: `${component} with spread props without sx`, + code: `${prcImport}${testIdObjectDeclaration}${jsx(`<${component} {...props}>Hello World`)}`, + output: `${prcImport}${testIdObjectDeclaration}${jsx(`<${replacement} {...props}>Hello World`)}`, + errors: [{messageId}], + filename, + }, + { + name: `${component} with string element 'as' prop`, + code: `${prcImport}${jsx(`<${component} as="code">Hello world`)}`, + // There is extra whitespace here we don't worry about since formatters would get rid of it + output: `${prcImport}${jsx(`Hello world`)}`, + errors: [{messageId}], + filename, + }, + { + name: `${component} with single-character 'as' prop`, + code: `${prcImport}${jsx(`<${component} as="p">Hello world`)}`, + output: `${prcImport}${jsx(`

Hello world

`)}`, + errors: [{messageId}], + filename, + }, + { + name: `${component} with string element 'as' prop surrounded by unnecessary braces`, + code: `${prcImport}${jsx(`<${component} as={"code"}>Hello world`)}`, + output: `${prcImport}${jsx(`Hello world`)}`, + errors: [{messageId}], + filename, + }, + { + name: `${component} with component reference 'as' prop`, + code: `${prcImport}${componentDeclaration}${jsx(`<${component} as={OtherComponent}>Hello world`)}`, + output: `${prcImport}${componentDeclaration}${jsx(`Hello world`)}`, + errors: [{messageId}], + filename, + }, + { + name: `${component} with spread 'as' prop`, + code: `${prcImport}${asObjectDeclaration}${jsx(`<${component} {...props}>Hello world`)}`, + output: null, + errors: [{messageId}], + filename, + }, + { + name: `${component} with unusable lowercase reference 'as' prop`, + code: `${prcImport}${asConstDeclaration}${jsx(`<${component} as={as}>Hello world`)}`, + output: null, + errors: [{messageId}], + filename, + }, + { + name: `Non-PRC ${component} when \`skipImportCheck\` is enabled`, + code: `${brandImport}${jsx(`<${component}>Hello World`)}`, + output: `${brandImport}${jsx(`<${replacement}>Hello World`)}`, + filename, + errors: [{messageId}], + options: [{skipImportCheck: true}], + }, + ]), +}) diff --git a/src/rules/no-unnecessary-components.js b/src/rules/no-unnecessary-components.js new file mode 100644 index 00000000..c5701d6a --- /dev/null +++ b/src/rules/no-unnecessary-components.js @@ -0,0 +1,155 @@ +// @ts-check + +const {ESLintUtils} = require('@typescript-eslint/utils') +const {IndexKind} = require('typescript') +const {pick: pickStyledSystemProps} = require('@styled-system/props') +const {isPrimerComponent} = require('../utils/is-primer-component') + +/** @typedef {import('@typescript-eslint/types').TSESTree.JSXAttribute} JSXAttribute */ + +const components = { + Box: { + replacement: 'div', + messageId: 'unecessaryBox', + message: 'Prefer plain HTML elements over `Box` when not using `sx` for styling.', + }, + Text: { + replacement: 'span', + messageId: 'unecessarySpan', + message: 'Prefer plain HTML elements over `Text` when not using `sx` for styling.', + }, +} + +const elementNameRegex = /^[a-z]\w*$/ +const componentNameRegex = /^[A-Z][\w._]*$/ + +/** @param {string} propName */ +const isStyledSystemProp = propName => propName in pickStyledSystemProps({[propName]: propName}) + +const rule = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + docs: { + description: + '`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.', + }, + messages: { + [components.Box.messageId]: components.Box.message, + [components.Text.messageId]: components.Text.message, + }, + type: 'problem', + schema: [ + { + type: 'object', + properties: { + skipImportCheck: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + fixable: 'code', + }, + defaultOptions: [{skipImportCheck: false}], + create(context) { + return { + JSXElement({openingElement, closingElement}) { + const {name, attributes} = openingElement + + // Ensure this is one of the components we are looking for. Note this doesn't account for import aliases; this + // is intentional to avoid having to do the scope tree traversal for every component of every name, which would + // be needlessly expensive. We just ignore aliased imports. + if (name.type !== 'JSXIdentifier' || !(name.name in components)) return + const componentConfig = components[/** @type {keyof typeof components} */ (name.name)] + + // Only continue if the variable declaration is an import from @primer/react. Otherwise it could, for example, + // be an import from @primer/brand, which would be valid without sx. + const skipImportCheck = context.options[0]?.skipImportCheck + const isPrimer = skipImportCheck || isPrimerComponent(name, context.sourceCode.getScope(openingElement)) + if (!isPrimer) return + + // Validate the attributes and ensure an `sx` prop is present or spreaded in + /** @type {typeof attributes[number] | undefined | null} */ + let asProp = undefined + for (const attribute of attributes) { + // If there is a spread type, check if the type of the spreaded value has an `sx` property + if (attribute.type === 'JSXSpreadAttribute') { + const services = ESLintUtils.getParserServices(context) + const typeChecker = services.program.getTypeChecker() + + const spreadType = services.getTypeAtLocation(attribute.argument) + if (typeChecker.getPropertyOfType(spreadType, 'sx') !== undefined) return + + // Check if the spread type has a string index signature - this could hide an `sx` property + if (typeChecker.getIndexTypeOfType(spreadType, IndexKind.String) !== undefined) return + + // If there is an `as` inside the spread object, we can't autofix reliably + if (typeChecker.getPropertyOfType(spreadType, 'as') !== undefined) asProp = null + + continue + } + + // Has sx prop, so should keep using this component + if ( + attribute.name.type === 'JSXIdentifier' && + (attribute.name.name === 'sx' || isStyledSystemProp(attribute.name.name)) + ) + return + + // If there is an `as` prop we will need to account for that when autofixing + if (attribute.name.type === 'JSXIdentifier' && attribute.name.name === 'as') asProp = attribute + } + + // Determine a replacement component name accounting for the `as` prop if present + /** @type {string | null} */ + let replacement = componentConfig.replacement + if (asProp === null) { + // {...{as: 'something-unusable'}} + replacement = null + } else if (asProp?.type === 'JSXAttribute') { + // as={ComponentReference} + if (asProp.value?.type === 'JSXExpressionContainer' && asProp.value.expression.type === 'Identifier') { + // can't just use expression.name here because we want the whole expression if it's A.B + const expressionStr = context.sourceCode.getText(asProp.value.expression) + replacement = componentNameRegex.test(expressionStr) ? expressionStr : null + } + // as={'tagName'} (surprisingly common, we really should enable `react/jsx-curly-brace-presence`) + else if ( + asProp.value?.type === 'JSXExpressionContainer' && + asProp.value.expression.type === 'Literal' && + typeof asProp.value.expression.value === 'string' && + elementNameRegex.test(asProp.value.expression.value) + ) { + replacement = asProp.value.expression.value + } + // as="tagName" + else if ( + asProp.value?.type === 'Literal' && + typeof asProp.value.value === 'string' && + elementNameRegex.test(asProp.value.value) + ) { + replacement = asProp.value.value + } + // too complex to autofix + else { + replacement = null + } + } + + context.report({ + node: name, + messageId: componentConfig.messageId, + fix: replacement + ? function* (fixer) { + yield fixer.replaceText(name, replacement) + if (closingElement) yield fixer.replaceText(closingElement.name, replacement) + if (asProp) yield fixer.remove(asProp) + } + : undefined, + }) + }, + } + }, +}) + +module.exports = {...rule, components} diff --git a/src/utils/is-primer-component.js b/src/utils/is-primer-component.js index b5f1c543..10b1ed6e 100644 --- a/src/utils/is-primer-component.js +++ b/src/utils/is-primer-component.js @@ -1,5 +1,6 @@ const {isImportedFrom} = require('./is-imported-from') +/** @returns {boolean} */ function isPrimerComponent(name, scope) { let identifier