Skip to content

Commit 017dfb4

Browse files
committed
Add importedFrom config option and validate config
1 parent fd0fea2 commit 017dfb4

File tree

9 files changed

+579
-88
lines changed

9 files changed

+579
-88
lines changed

README.md

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# react-scanner
44

5-
`react-scanner` statically analyzes the given code and extracts React components and props usage.
5+
`react-scanner` statically analyzes the given code (TypeScript supported) and extracts React components and props usage.
66

77
First, it crawls the given directory and compiles a list of files to be scanned. Then, it scans every file by extracting rendered components and their props into a JSON report.
88

@@ -134,37 +134,52 @@ The config file can be located anywhere and it must export an object like this:
134134
```js
135135
module.exports = {
136136
// [required]
137+
// Type: string
137138
// The path of the directory to start crawling from (absolute or relative to the config file location).
138139
crawlFrom: "./src",
139140

140141
// [optional]
142+
// Type: function
141143
// Directory names to exclude from crawling.
142144
exclude: (dir) => {
143145
// Note: dir is just the directory name, not the path.
144146
return ["utils", "tests"].includes(dir);
145147
},
146148

147-
// [optional] defaults to: ["**/!(*.test|*.spec).@(js|ts)?(x)"]
149+
// [optional]
150+
// Type: array of strings (globs)
151+
// Default: ["**/!(*.test|*.spec).@(js|ts)?(x)"]
148152
// Only files matching these globs will be scanned (see here for glob syntax: https://github.com/micromatch/picomatch#globbing-features).
149153
globs: ["**/*.js"],
150154

151155
// [optional]
152-
// Components to report on (omit to report on all components).
156+
// Type: object where all values are true
157+
// Components to report (omit to report all components).
153158
components: {
154159
Button: true,
155160
Footer: true,
156161
Text: true,
157162
},
158163

159-
// [optional] defaults to: false
160-
// Whether to report on subcomponents or not.
161-
// false - Footer will be reported on, but Footer.Content will not.
162-
// true - Footer.Content will be reported on, as well as Footer.Content.Legal, etc.
164+
// [optional]
165+
// Type: boolean
166+
// Default: false
167+
// Whether to report subcomponents or not.
168+
// false - Footer will be reported, but Footer.Content will not.
169+
// true - Footer.Content will be reported, as well as Footer.Content.Legal, etc.
163170
includeSubComponents: true,
164171

165172
// [optional]
173+
// Type: string or RegExp.
174+
// Before reporting a component, we'll check if it's imported from a module name matching importedFrom.
175+
// Only if there is a match, the component will be reported.
176+
// When omitted, this check is bypassed.
177+
importedFrom: "basis",
178+
179+
// [optional]
180+
// Type: function
166181
// Specify what to do with the report.
167-
// In this example, we count how many times each component is used, sort
182+
// In this example, we count how many times each component and its props is used, sort
168183
// by count, and write the result to a file.
169184
// Note, the components in the report will be nested when includeSubComponents is true.
170185
// To help traversing the report, we provide a convenience forEachComponent function.
@@ -181,12 +196,31 @@ module.exports = {
181196
forEachComponent(({ componentName, component }) => {
182197
const { instances } = component;
183198

184-
if (instances) {
185-
output[componentName] = instances.length;
199+
if (!instances) {
200+
return;
186201
}
202+
203+
output[componentName] = {
204+
instances: instances.length,
205+
props: {},
206+
};
207+
208+
instances.forEach((instance) => {
209+
for (const prop in instance.props) {
210+
if (output[componentName].props[prop] === undefined) {
211+
output[componentName].props[prop] = 0;
212+
}
213+
214+
output[componentName].props[prop] += 1;
215+
}
216+
});
217+
218+
output[componentName].props = sortObjectKeysByValue(
219+
output[componentName].props
220+
);
187221
});
188222

189-
output = sortObjectKeysByValue(output);
223+
output = sortObjectKeysByValue(output, (component) => component.instances);
190224

191225
writeFile(
192226
"./reports/oscar.json", // absolute or relative to the config file location
@@ -196,6 +230,33 @@ module.exports = {
196230
};
197231
```
198232
233+
This `processReport` would produce something like this:
234+
235+
```json
236+
{
237+
"Text": {
238+
"instances": 17,
239+
"props": {
240+
"margin": 6,
241+
"color": 4,
242+
"textStyle": 1
243+
}
244+
},
245+
"Button": {
246+
"instances": 10,
247+
"props": {
248+
"width": 10,
249+
"variant": 5,
250+
"type": 3
251+
}
252+
},
253+
"Footer": {
254+
"instances": 1,
255+
"props": {}
256+
}
257+
}
258+
```
259+
199260
## License
200261
201262
MIT

package-lock.json

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"dlv": "1.1.3",
2222
"dset": "2.0.1",
2323
"fdir": "3.4.3",
24+
"is-plain-object": "4.1.1",
2425
"picomatch": "2.2.2",
2526
"sade": "1.7.3",
2627
"typescript": "3.9.7"

renovate.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"packageRules": [
66
{
77
"packagePatterns": ["*"],
8+
"depTypeList": ["dependencies", "devDependencies"],
89
"minor": {
910
"groupName": "all non-major dependencies",
1011
"groupSlug": "all-minor-patch"

src/run.js

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function run({ config, configDir, crawlFrom, startTime }) {
2020
.sync();
2121

2222
let report = {};
23-
const { components, includeSubComponents } = config;
23+
const { components, includeSubComponents, importedFrom } = config;
2424

2525
for (let i = 0, len = files.length; i < len; i++) {
2626
const filePath = files[i];
@@ -31,10 +31,22 @@ function run({ config, configDir, crawlFrom, startTime }) {
3131
filePath,
3232
components,
3333
includeSubComponents,
34+
importedFrom,
3435
report,
3536
});
3637
}
3738

39+
const logSummary = () => {
40+
const endTime = process.hrtime.bigint();
41+
42+
// eslint-disable-next-line no-console
43+
console.log(
44+
`Scanned ${pluralize(files.length, "file")} in ${
45+
Number(endTime - startTime) / 1e9
46+
} seconds`
47+
);
48+
};
49+
3850
if (typeof config.processReport === "function") {
3951
config.processReport({
4052
report,
@@ -47,23 +59,18 @@ function run({ config, configDir, crawlFrom, startTime }) {
4759
fs.mkdirSync(path.dirname(filePath), { recursive: true });
4860
fs.writeFileSync(filePath, data);
4961

62+
logSummary();
63+
5064
// eslint-disable-next-line no-console
5165
console.log(`See: ${filePath}`);
5266
},
5367
});
5468
} else {
69+
logSummary();
70+
5571
// eslint-disable-next-line no-console
5672
console.log(JSON.stringify(report, null, 2));
5773
}
58-
59-
const endTime = process.hrtime.bigint();
60-
61-
// eslint-disable-next-line no-console
62-
console.log(
63-
`Scanned ${pluralize(files.length, "file")} in ${
64-
Number(endTime - startTime) / 1e9
65-
} seconds`
66-
);
6774
}
6875

6976
module.exports = run;

src/scan.js

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ function scan({
8181
filePath,
8282
components,
8383
includeSubComponents = false,
84+
importedFrom,
8485
report,
8586
}) {
8687
let ast;
@@ -92,40 +93,89 @@ function scan({
9293
return;
9394
}
9495

95-
astray.walk(
96-
ast,
97-
{
98-
JSXOpeningElement(node, report) {
99-
const name = getComponentName(node.name);
100-
const nameParts = name.split(".");
101-
const shouldScanComponent =
102-
!components ||
103-
(components[nameParts[0]] &&
104-
(nameParts.length === 1 || includeSubComponents));
105-
106-
if (!shouldScanComponent) {
107-
return astray.SKIP;
96+
const importsMap = {};
97+
98+
astray.walk(ast, {
99+
ImportDeclaration(node) {
100+
const { source, specifiers } = node;
101+
const moduleName = source.value;
102+
const specifiersCount = specifiers.length;
103+
104+
for (let i = 0; i < specifiersCount; i++) {
105+
switch (specifiers[i].type) {
106+
case "ImportDefaultSpecifier":
107+
case "ImportSpecifier":
108+
case "ImportNamespaceSpecifier": {
109+
const imported = specifiers[i].local.name;
110+
111+
importsMap[imported] = moduleName;
112+
break;
113+
}
114+
115+
/* c8 ignore next 5 */
116+
default: {
117+
throw new Error(
118+
`Unknown import specifier type: ${specifiers[i].type}`
119+
);
120+
}
121+
}
122+
}
123+
},
124+
JSXOpeningElement(node) {
125+
const name = getComponentName(node.name);
126+
const nameParts = name.split(".");
127+
const shouldReportComponent = () => {
128+
if (components) {
129+
if (
130+
components[name] === undefined &&
131+
components[nameParts[0]] === undefined
132+
) {
133+
return false;
134+
}
108135
}
109136

110-
const componentPath = nameParts.join(".components.");
111-
let componentInfo = getObjectPath(report, componentPath);
112-
113-
if (!componentInfo) {
114-
componentInfo = {};
115-
setObjectPath(report, componentPath, componentInfo);
137+
if (includeSubComponents === false) {
138+
if (nameParts.length > 1) {
139+
return false;
140+
}
116141
}
117142

118-
if (!componentInfo.instances) {
119-
componentInfo.instances = [];
143+
if (importedFrom) {
144+
const actualImportedFrom = importsMap[nameParts[0]];
145+
146+
if (importedFrom instanceof RegExp) {
147+
if (importedFrom.test(actualImportedFrom) === false) {
148+
return false;
149+
}
150+
} else if (actualImportedFrom !== importedFrom) {
151+
return false;
152+
}
120153
}
121154

122-
const info = getInstanceInfo(node, filePath);
155+
return true;
156+
};
157+
158+
if (!shouldReportComponent()) {
159+
return astray.SKIP;
160+
}
161+
162+
const componentPath = nameParts.join(".components.");
163+
let componentInfo = getObjectPath(report, componentPath);
164+
165+
if (!componentInfo) {
166+
componentInfo = {};
167+
setObjectPath(report, componentPath, componentInfo);
168+
}
169+
170+
if (!componentInfo.instances) {
171+
componentInfo.instances = [];
172+
}
173+
174+
const info = getInstanceInfo(node, filePath);
123175

124-
componentInfo.instances.push(info);
125-
},
176+
componentInfo.instances.push(info);
126177
},
127-
report
128-
);
178+
});
129179
}
130180

131181
module.exports = scan;

0 commit comments

Comments
 (0)