Skip to content

Commit 7e0b0bc

Browse files
committed
Initial commit [publish]
0 parents  commit 7e0b0bc

File tree

11 files changed

+1219
-0
lines changed

11 files changed

+1219
-0
lines changed

.github/workflows/publish.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Publish to npm
2+
on:
3+
push:
4+
branches:
5+
- main
6+
jobs:
7+
build:
8+
runs-on: ubuntu-latest
9+
if: ${{ contains(github.event.head_commit.message, '[publish]') }}
10+
steps:
11+
- uses: actions/checkout@v2
12+
- run: yarn install --frozen-lockfile
13+
- run: yarn build
14+
- run: yarn test
15+
# Setup .npmrc file to publish to npm
16+
- uses: actions/setup-node@v2
17+
with:
18+
node-version: '16.x'
19+
registry-url: 'https://registry.npmjs.org'
20+
- run: npm publish
21+
env:
22+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.idea/
2+
node_modules/
3+
src/*.js

.npmignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.ts
2+
tsconfig.json
3+
test*

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) Arnaud Barré (https://github.com/ArnaudBarre)
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# eslint-plugin-react-refresh
2+
3+
Validate that your components can safely be updated with fast refresh.
4+
5+
⚠️ To avoid false positive, this plugin is only applied on `tsx` & `jsx` files.
6+
7+
## Installation
8+
9+
```sh
10+
npm i -D eslint-plugin-react-refresh
11+
```
12+
13+
## Usage
14+
15+
```json
16+
{
17+
"plugins": ["react-refresh"],
18+
"rules": {
19+
"react-refresh/only-export-components": "warn"
20+
}
21+
}
22+
```
23+
24+
## Fail
25+
26+
```jsx
27+
export const foo = () => {};
28+
export const Bar = () => <></>;
29+
```
30+
31+
```jsx
32+
export const CONSTANT = 3;
33+
export const Foo = () => <></>;
34+
```
35+
36+
```jsx
37+
export default function () {}
38+
```
39+
40+
```jsx
41+
export * from "./foo";
42+
```
43+
44+
## Pass
45+
46+
```js
47+
export default function Foo() {
48+
return <></>;
49+
}
50+
```
51+
52+
```js
53+
const foo = () => {};
54+
export const Bar = () => <></>;
55+
```

package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "eslint-plugin-react-refresh",
3+
"description": "Validate that your components can safely be updated with fast refresh",
4+
"version": "0.1.0",
5+
"license": "MIT",
6+
"author": "Arnaud Barré (https://github.com/ArnaudBarre)",
7+
"main": "src/index.js",
8+
"keywords": [
9+
"eslint",
10+
"eslint-plugin",
11+
"react",
12+
"react-refresh",
13+
"fast refresh"
14+
],
15+
"scripts": {
16+
"build": "tsc",
17+
"test": "node src/tests.js"
18+
},
19+
"prettier": {
20+
"trailingComma": "all"
21+
},
22+
"peerDependencies": {
23+
"eslint": ">=7"
24+
},
25+
"devDependencies": {
26+
"@types/eslint": "^8.4.0",
27+
"@types/node": "^17.0.10",
28+
"@typescript-eslint/experimental-utils": "^5.10.0",
29+
"eslint": "^8.7.0",
30+
"prettier": "^2.5.1",
31+
"typescript": "^4.5.5"
32+
}
33+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const rules = {
2+
"only-export-components": require("./only-export-components.ts").rule,
3+
};

src/only-export-components.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { TSESLint } from "@typescript-eslint/experimental-utils";
2+
import { TSESTree } from "@typescript-eslint/types";
3+
import { ExportDeclaration } from "@typescript-eslint/types/dist/ast-spec";
4+
5+
export const rule: TSESLint.RuleModule<
6+
"exportAll" | "namedExport" | "anonymousExport"
7+
> = {
8+
meta: {
9+
messages: {
10+
exportAll:
11+
"This rule can't verify that `export *` only export components",
12+
namedExport:
13+
"Fast refresh only works when a file only export components. Use a new file to share constant or functions between components.",
14+
anonymousExport:
15+
"Fast refresh can't handle anonymous component. Add a name to your export.",
16+
},
17+
type: "problem",
18+
schema: [],
19+
},
20+
create: (context) => {
21+
if (!context.getFilename().endsWith("x")) return {};
22+
23+
return {
24+
Program(program) {
25+
let mayHaveReactExport = false;
26+
const nonComponentExport: TSESTree.BindingName[] = [];
27+
28+
const handleIdentifier = (identifierNode: TSESTree.BindingName) => {
29+
if (identifierNode.type !== "Identifier") {
30+
nonComponentExport.push(identifierNode);
31+
return;
32+
}
33+
if (/^[A-Z][a-zA-Z]*$/.test(identifierNode.name)) {
34+
mayHaveReactExport = true;
35+
}
36+
// Only letters, starts with uppercase and at least one lowercase
37+
// This can lead to some false positive (ex: `const CMS = () => <></>`)
38+
// But allow to catch `export const CONSTANT = 3`
39+
if (!/^[A-Z][a-zA-Z]*[a-z]+[a-zA-Z]*$/.test(identifierNode.name)) {
40+
nonComponentExport.push(identifierNode);
41+
}
42+
};
43+
44+
const handleExportDeclaration = (node: ExportDeclaration) => {
45+
if (node.type === "VariableDeclaration") {
46+
for (const variable of node.declarations) {
47+
handleIdentifier(variable.id);
48+
}
49+
} else if (node.type === "FunctionDeclaration") {
50+
if (node.id === null) {
51+
context.report({ messageId: "anonymousExport", node });
52+
} else {
53+
handleIdentifier(node.id);
54+
}
55+
}
56+
};
57+
58+
for (const node of program.body) {
59+
if (node.type === "ExportAllDeclaration") {
60+
context.report({ messageId: "exportAll", node });
61+
} else if (node.type === "ExportDefaultDeclaration") {
62+
if (
63+
node.declaration.type === "VariableDeclaration" ||
64+
node.declaration.type === "FunctionDeclaration"
65+
) {
66+
handleExportDeclaration(node.declaration);
67+
}
68+
if (
69+
node.declaration.type === "ArrowFunctionExpression" &&
70+
!node.declaration.id
71+
) {
72+
context.report({ messageId: "anonymousExport", node });
73+
}
74+
} else if (node.type === "ExportNamedDeclaration") {
75+
if (node.declaration) handleExportDeclaration(node.declaration);
76+
for (const specifier of node.specifiers) {
77+
handleIdentifier(specifier.exported);
78+
}
79+
}
80+
}
81+
82+
if (!mayHaveReactExport) return;
83+
84+
for (const node of nonComponentExport) {
85+
context.report({ messageId: "namedExport", node });
86+
}
87+
},
88+
};
89+
},
90+
};

src/tests.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { RuleTester } from "eslint";
2+
import { rule } from "./only-export-components";
3+
4+
const ruleTester = new RuleTester({
5+
parserOptions: { sourceType: "module", ecmaVersion: 2018 },
6+
});
7+
8+
const valid = [
9+
{
10+
name: "Direct export named component",
11+
code: "export function Foo() {};",
12+
},
13+
{
14+
name: "Export named component",
15+
code: "function Foo() {}; export { Foo };",
16+
},
17+
{
18+
name: "Export default named component",
19+
code: "function Foo() {}; export default Foo;",
20+
},
21+
{
22+
name: "Direct export default named component",
23+
code: "export default function Foo() {}",
24+
},
25+
{
26+
name: "Direct export AF component",
27+
code: "export const Foo = () => {};",
28+
},
29+
{
30+
name: "Export AF component",
31+
code: "const Foo = () => {}; export { Foo };",
32+
},
33+
{
34+
name: "Default export AF component",
35+
code: "const Foo = () => {}; export default Foo;",
36+
},
37+
{
38+
name: "Two components & local variable",
39+
code: "const foo = 4; export const Bar = () => {}; export const Baz = () => {};",
40+
},
41+
{
42+
name: "Two components & local function",
43+
code: "const foo = () => {}; export const Bar = () => {}; export const Baz = () => {};",
44+
},
45+
{
46+
name: "Direct export variable",
47+
code: "export const foo = 3;",
48+
},
49+
{
50+
name: "Export variables",
51+
code: "const foo = 3; const bar = 'Hello'; export { foo, bar };",
52+
},
53+
{
54+
name: "Direct export AF",
55+
code: "export const foo = () => {};",
56+
},
57+
{
58+
name: "Direct export default AF",
59+
code: "export default function foo () {};",
60+
},
61+
];
62+
63+
const invalid = [
64+
{
65+
name: "Component and function",
66+
code: "export const foo = () => {}; export const Bar = () => {};",
67+
errorId: "namedExport",
68+
},
69+
{
70+
name: "Component and variable (direct export)",
71+
code: "export const foo = 4; export const Bar = () => {};",
72+
errorId: "namedExport",
73+
},
74+
{
75+
name: "Component and variable",
76+
code: "const foo = 4; const Bar = () => {}; export { foo, Bar };",
77+
errorId: "namedExport",
78+
},
79+
{
80+
name: "Export all",
81+
code: "export * from './foo';",
82+
errorId: "exportAll",
83+
},
84+
{
85+
name: "Export default anonymous AF",
86+
code: "export default () => {};",
87+
errorId: "anonymousExport",
88+
},
89+
{
90+
name: "Export default anonymous function",
91+
code: "export default function () {};",
92+
errorId: "anonymousExport",
93+
},
94+
{
95+
name: "Component and constant",
96+
code: "export const CONSTANT = 3; export const Foo = () => {};",
97+
errorId: "namedExport",
98+
},
99+
];
100+
101+
let failedTests = 0;
102+
103+
const it = (name: string, cases: Parameters<typeof ruleTester.run>[2]) => {
104+
try {
105+
ruleTester.run(
106+
"only-export-components",
107+
// @ts-ignore Mismatch between typescript-eslint and eslint
108+
rule,
109+
cases,
110+
);
111+
console.log(`${name} ✅`);
112+
} catch (e) {
113+
console.log(`${name} ❌`);
114+
console.error(e);
115+
failedTests++;
116+
}
117+
};
118+
119+
valid.forEach(({ name, code }) => {
120+
it(name, {
121+
valid: [{ filename: "Test.jsx", code }],
122+
invalid: [],
123+
});
124+
});
125+
126+
invalid.forEach(({ name, code, errorId }) => {
127+
it(name, {
128+
valid: [],
129+
invalid: [{ filename: "Test.jsx", code, errors: [{ messageId: errorId }] }],
130+
});
131+
});
132+
133+
if (failedTests) {
134+
console.log(`${failedTests} tests failed`);
135+
process.exit(1);
136+
}

tsconfig.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"include": ["src"],
3+
"compilerOptions": {
4+
/* Target node12 */
5+
"module": "CommonJS",
6+
"lib": ["ES2019"],
7+
"target": "ES2019",
8+
9+
/* Imports */
10+
"moduleResolution": "node", // Allow `index` imports
11+
"resolveJsonModule": true, // Allow json import
12+
"forceConsistentCasingInFileNames": true, // Avoid difference in case between file name and import
13+
"allowSyntheticDefaultImports": true, // Allow import fs from "fs"
14+
15+
/* Linting */
16+
"strict": true,
17+
"noUnusedLocals": true,
18+
"noUnusedParameters": true,
19+
"noFallthroughCasesInSwitch": true,
20+
"useUnknownInCatchVariables": true
21+
}
22+
}

0 commit comments

Comments
 (0)