Skip to content

Commit 40cdaaa

Browse files
committed
create exportOnly rule for no-multi-comp
1 parent 5c23573 commit 40cdaaa

File tree

3 files changed

+201
-4
lines changed

3 files changed

+201
-4
lines changed

docs/rules/no-multi-comp.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ class HelloJohn extends React.Component {
6969
module.exports = HelloJohn;
7070
```
7171

72+
### `exportOnly`
73+
74+
When `true` the rule will ignore components which are not exported, which allows you to define components as long as they are only used within a private scope.
75+
7276
## When Not To Use It
7377

7478
If you prefer to declare multiple components per file you can disable this rule.

lib/rules/no-multi-comp.js

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const report = require('../util/report');
1717

1818
const messages = {
1919
onlyOneComponent: 'Declare only one React component per file',
20+
onlyOneExportedComponent: 'Declare only one exported React component per file',
2021
};
2122

2223
/** @type {import('eslint').Rule.RuleModule} */
@@ -38,6 +39,10 @@ module.exports = {
3839
default: false,
3940
type: 'boolean',
4041
},
42+
exportOnly: {
43+
default: false,
44+
type: 'boolean',
45+
},
4146
},
4247
additionalProperties: false,
4348
}],
@@ -46,6 +51,43 @@ module.exports = {
4651
create: Components.detect((context, components, utils) => {
4752
const configuration = context.options[0] || {};
4853
const ignoreStateless = configuration.ignoreStateless || false;
54+
const exportOnly = configuration.exportOnly || false;
55+
56+
const exportedComponents = new Set(); // Track exported components
57+
const validIdentifiers = ['ArrowFunctionExpression', 'Identifier', 'FunctionExpression'];
58+
59+
/**
60+
* Given an export declaration, find the export name.
61+
* @param {Object} node
62+
* @returns {string}
63+
*/
64+
function getExportedComponentName(node) {
65+
if (node.declaration.type === 'ClassDeclaration') {
66+
return node.declaration.id.name;
67+
}
68+
for (const declarator of node.declaration.declarations || []) {
69+
const type = declarator.init.type;
70+
if (validIdentifiers.find(type)) {
71+
return declarator.id.name;
72+
}
73+
}
74+
}
75+
76+
/**
77+
* Given a React component, find the exported name.
78+
* @param {Object} component
79+
* @returns {string}
80+
*/
81+
function findComponentIdentifierFromComponent(component) {
82+
let name;
83+
if (component.node.parent) {
84+
name = component.node.parent.id.name;
85+
}
86+
if (!name) {
87+
name = component.node.id.name;
88+
}
89+
return name;
90+
}
4991

5092
/**
5193
* Checks if the component is ignored
@@ -61,21 +103,46 @@ module.exports = {
61103
);
62104
}
63105

64-
return {
106+
/**
107+
* Checks if the component is exported, if exportOnly is set
108+
* @param {Object} component The component being checked.
109+
* @returns {boolean} True if the component is exported or exportOnly is false
110+
*/
111+
function isExported(component) {
112+
return !exportOnly && exportedComponents.has(findComponentIdentifierFromComponent(component));
113+
}
114+
115+
const rule = {
65116
'Program:exit'() {
66117
if (components.length() <= 1) {
67118
return;
68119
}
69120

70121
values(components.list())
71122
.filter((component) => !isIgnored(component))
123+
.filter((component) => isExported(component))
72124
.slice(1)
73125
.forEach((component) => {
74-
report(context, messages.onlyOneComponent, 'onlyOneComponent', {
75-
node: component.node,
76-
});
126+
report(context,
127+
exportOnly ? messages.onlyOneExportedComponent : messages.onlyOneComponent,
128+
exportOnly ? 'onlyOneExportedComponent' : 'onlyOneComponent',
129+
{
130+
node: component.node,
131+
});
77132
});
78133
},
79134
};
135+
136+
if (exportOnly) {
137+
rule.ExportNamedDeclaration = (node) => {
138+
exportedComponents.add(getExportedComponentName(node));
139+
};
140+
141+
rule.ExportDefaultDeclaration = (node) => {
142+
exportedComponents.add(getExportedComponentName(node));
143+
};
144+
}
145+
146+
return rule;
80147
}),
81148
};

tests/lib/rules/no-multi-comp.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,69 @@ ruleTester.run('no-multi-comp', rule, {
265265
export default MenuList
266266
`,
267267
},
268+
{
269+
code: `
270+
const componentOne = () => <></>;
271+
const componentTwo = () => <></>;
272+
`,
273+
options: [{ exportOnly: true }],
274+
},
275+
{
276+
code: `
277+
export const componentOne = () => <></>;
278+
const componentTwo = () => <></>;
279+
`,
280+
options: [{ exportOnly: true }],
281+
},
282+
{
283+
code: `
284+
const componentOne = () => <></>;
285+
const componentTwo = () => <></>;
286+
module.exports = { componentOne };
287+
`,
288+
options: [{ exportOnly: true }],
289+
},
290+
{
291+
code: `
292+
const componentOne = () => <></>;
293+
const componentTwo = () => <></>;
294+
export default componentOne;
295+
`,
296+
options: [{ exportOnly: true }],
297+
},
298+
{
299+
code: `
300+
function componentOne() { return <></> };
301+
const componentTwo = () => <></>;
302+
export default componentOne;
303+
`,
304+
options: [{ exportOnly: true }],
305+
},
306+
{
307+
code: `
308+
function componentOne() { return <></> };
309+
function componentTwo() { return <></> };
310+
export default componentOne;
311+
`,
312+
options: [{ exportOnly: true }],
313+
},
314+
{
315+
code: `
316+
import React, {Component} from "react";
317+
export class componentOne extends Component() { render() { return <></>; }};
318+
function componentTwo() { return <></> };
319+
`,
320+
options: [{ exportOnly: true }],
321+
},
322+
{
323+
code: `
324+
import React, {Component} from "react";
325+
class componentOne extends Component() { render() { return <></>; }};
326+
function componentTwo() { return <></> };
327+
export default componentOne;
328+
`,
329+
options: [{ exportOnly: true }],
330+
},
268331
]),
269332

270333
invalid: parsers.all([
@@ -612,5 +675,68 @@ ruleTester.run('no-multi-comp', rule, {
612675
},
613676
errors: [{ messageId: 'onlyOneComponent' }],
614677
},
678+
{
679+
code: `
680+
export const componentOne = () => <></>;
681+
export const componentTwo = () => <></>;
682+
`,
683+
options: [{ exportOnly: true }],
684+
errors: [{ messageId: 'onlyOneExportedComponent' }],
685+
},
686+
{
687+
code: `
688+
const componentOne = () => <></>;
689+
const componentTwo = () => <></>;
690+
module.exports = { componentOne, componentTwo };
691+
`,
692+
options: [{ exportOnly: true }],
693+
errors: [{ messageId: 'onlyOneExportedComponent' }],
694+
},
695+
{
696+
code: `
697+
const componentOne = () => <></>;
698+
export const componentTwo = () => <></>;
699+
export default componentOne;
700+
`,
701+
options: [{ exportOnly: true }],
702+
errors: [{ messageId: 'onlyOneExportedComponent' }],
703+
},
704+
{
705+
code: `
706+
export function componentOne() { return <></> };
707+
export const componentTwo = () => <></>;
708+
export default componentTwo;
709+
`,
710+
options: [{ exportOnly: true }],
711+
errors: [{ messageId: 'onlyOneExportedComponent' }],
712+
},
713+
{
714+
code: `
715+
function componentOne() { return <></> };
716+
export function componentTwo() { return <></> };
717+
export default componentOne;
718+
`,
719+
options: [{ exportOnly: true }],
720+
errors: [{ messageId: 'onlyOneExportedComponent' }],
721+
},
722+
{
723+
code: `
724+
import React, {Component} from "react";
725+
export class componentOne extends Component() { render() { return <></>; }};
726+
export function componentTwo() { return <></> };
727+
`,
728+
options: [{ exportOnly: true }],
729+
errors: [{ messageId: 'onlyOneExportedComponent' }],
730+
},
731+
{
732+
code: `
733+
import React, {Component} from "react";
734+
class componentOne extends Component() { render() { return <></>; }};
735+
export function componentTwo() { return <></> };
736+
export default componentOne;
737+
`,
738+
options: [{ exportOnly: true }],
739+
errors: [{ messageId: 'onlyOneExportedComponent' }],
740+
},
615741
]),
616742
});

0 commit comments

Comments
 (0)