├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .husky
├── .gitignore
└── pre-commit
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── bin
└── react-scanner
├── package-lock.json
├── package.json
├── renovate.json
├── scripts
└── processors.js
├── src
├── index.js
├── index.test.js
├── processors
│ ├── count-components-and-props.js
│ ├── count-components.js
│ ├── processors.json
│ └── raw-report.js
├── run.js
├── scan.js
├── scan.test.js
├── scanner.js
├── scanner.test.js
├── utils.js
└── utils.test.js
└── test
├── code
├── Home.js
└── index.js
└── configs
├── invalid.config.js
├── multipleProcessors.config.js
├── noFilesFound.config.js
├── noProcessors.config.js
└── singleProcessor.config.js
/.eslintignore:
--------------------------------------------------------------------------------
1 | test
2 | test/reports
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["eslint:recommended", "plugin:import/errors"],
3 | "plugins": ["import"],
4 | "rules": {
5 | "no-console": ["error", { "allow": ["error"] }],
6 | "no-unused-vars": [
7 | "error",
8 | { "argsIgnorePattern": "^_", "ignoreRestSiblings": true }
9 | ],
10 | "import/no-cycle": "error"
11 | },
12 | "env": {
13 | "es2020": true,
14 | "node": true
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | matrix:
11 | node-version: [16.17.0]
12 |
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v2
16 |
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v2
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 |
22 | - name: Install
23 | run: npm install
24 |
25 | - name: Lint
26 | run: npm run lint
27 |
28 | - name: Test
29 | run: npm run test:coverage
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | test/reports
4 | .DS_Store
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npm run build && npm run prettier && npm run lint && npm run test:coverage && git add src/processors/processors.json
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v16.17.0
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | coverage
2 | test/reports
3 | package-lock.json
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "useTabs": false,
4 | "tabWidth": 2,
5 | "semi": true,
6 | "singleQuote": false,
7 | "printWidth": 80
8 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 Misha Moroshko
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # react-scanner
4 |
5 | `react-scanner` statically analyzes the given code (TypeScript supported) and extracts React components and props usage.
6 |
7 | First, it crawls the given directory and compiles a list of files to be scanned. Then, it scans every file and extracts rendered components and their props into a JSON report.
8 |
9 | For example, let's say we have the following `index.js` file:
10 |
11 | ```jsx
12 | import React from "react";
13 | import ReactDOM from "react-dom";
14 | import {
15 | BasisProvider,
16 | defaultTheme,
17 | Container,
18 | Text,
19 | Link as BasisLink,
20 | } from "basis";
21 |
22 | function App() {
23 | return (
24 |
25 |
26 |
27 | Want to know how your design system components are being used?
28 |
29 |
30 | Try{" "}
31 |
32 | react-scanner
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | ReactDOM.render(, document.getElementById("root"));
41 | ```
42 |
43 | Running `react-scanner` on it will create the following JSON report:
44 |
45 |
46 | Click to see it
47 |
48 | ```json
49 | {
50 | "BasisProvider": {
51 | "instances": [
52 | {
53 | "importInfo": {
54 | "imported": "BasisProvider",
55 | "local": "BasisProvider",
56 | "moduleName": "basis"
57 | },
58 | "props": {
59 | "theme": "(Identifier)"
60 | },
61 | "propsSpread": false,
62 | "location": {
63 | "file": "/path/to/index.js",
64 | "start": {
65 | "line": 13,
66 | "column": 5
67 | }
68 | }
69 | }
70 | ]
71 | },
72 | "Container": {
73 | "instances": [
74 | {
75 | "importInfo": {
76 | "imported": "Container",
77 | "local": "Container",
78 | "moduleName": "basis"
79 | },
80 | "props": {
81 | "margin": "4",
82 | "hasBreakpointWidth": null
83 | },
84 | "propsSpread": false,
85 | "location": {
86 | "file": "/path/to/index.js",
87 | "start": {
88 | "line": 14,
89 | "column": 7
90 | }
91 | }
92 | }
93 | ]
94 | },
95 | "Text": {
96 | "instances": [
97 | {
98 | "importInfo": {
99 | "imported": "Text",
100 | "local": "Text",
101 | "moduleName": "basis"
102 | },
103 | "props": {
104 | "textStyle": "subtitle2"
105 | },
106 | "propsSpread": false,
107 | "location": {
108 | "file": "/path/to/index.js",
109 | "start": {
110 | "line": 15,
111 | "column": 9
112 | }
113 | }
114 | },
115 | {
116 | "importInfo": {
117 | "imported": "Text",
118 | "local": "Text",
119 | "moduleName": "basis"
120 | },
121 | "props": {
122 | "margin": "4 0 0 0"
123 | },
124 | "propsSpread": false,
125 | "location": {
126 | "file": "/path/to/index.js",
127 | "start": {
128 | "line": 18,
129 | "column": 9
130 | }
131 | }
132 | }
133 | ]
134 | },
135 | "Link": {
136 | "instances": [
137 | {
138 | "importInfo": {
139 | "imported": "Link",
140 | "local": "BasisLink",
141 | "moduleName": "basis"
142 | },
143 | "props": {
144 | "href": "https://github.com/moroshko/react-scanner",
145 | "newTab": null
146 | },
147 | "propsSpread": false,
148 | "location": {
149 | "file": "/path/to/index.js",
150 | "start": {
151 | "line": 20,
152 | "column": 11
153 | }
154 | }
155 | }
156 | ]
157 | }
158 | }
159 | ```
160 |
161 |
162 |
163 | This raw JSON report is used then to generate something that is useful to you. For example, you might want to know:
164 |
165 | - How often a cetrain component is used in your design system? (see [`count-components`](#count-components) processor)
166 | - How often a certain prop in a given component is used? (see [`count-components-and-props`](#count-components-and-props) processor)
167 | - Looking at some prop in a given component, what's the distribution of values used? (e.g. you might consider deprecating a certain value)
168 |
169 | Once you have the result you are interested in, you can write it to a file or simply log it to the console.
170 |
171 | ## Installation
172 |
173 | ```
174 | npm install --save-dev react-scanner
175 | ```
176 |
177 | ## Usage
178 |
179 | ```
180 | npx react-scanner -c /path/to/react-scanner.config.js
181 | ```
182 |
183 | ### Config file
184 |
185 | Everything that `react-scanner` does is controlled by a config file.
186 |
187 | The config file can be located anywhere and it must export an object like this:
188 |
189 | ```js
190 | module.exports = {
191 | crawlFrom: "./src",
192 | includeSubComponents: true,
193 | importedFrom: "basis",
194 | };
195 | ```
196 |
197 | Running `react-scanner` with this config would output something like this to the console:
198 |
199 | ```json
200 | {
201 | "Text": {
202 | "instances": 17,
203 | "props": {
204 | "margin": 6,
205 | "color": 4,
206 | "textStyle": 1
207 | }
208 | },
209 | "Button": {
210 | "instances": 10,
211 | "props": {
212 | "width": 10,
213 | "variant": 5,
214 | "type": 3
215 | }
216 | },
217 | "Footer": {
218 | "instances": 1,
219 | "props": {}
220 | }
221 | }
222 | ```
223 |
224 | ### Running programmatically
225 |
226 | It is also possible to run the scanner programmatically. In this case, the config options should be passed directly to the `run` function.
227 |
228 | ```js
229 | import scanner from "react-scanner";
230 |
231 | const output = await scanner.run(config);
232 | ```
233 |
234 | ## Config options
235 |
236 | Here are all the available config options:
237 |
238 | | Option | Type | Description |
239 | | ---------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
240 | | `rootDir` | string | The path to the root directory of your project.
If using a config file, this defaults to the config directory. |
241 | | `crawlFrom` | string | The path of the directory to start crawling from.
Absolute or relative to the config file location. |
242 | | `exclude` | array or function | Each array item should be a string or a regex. When crawling, if directory name matches exactly the string item or matches the regex item, it will be excluded from crawling.
For more complex scenarios, `exclude` can be a a function that accepts a directory name and should return `true` if the directory should be excluded from crawling. |
243 | | `globs` | array | Only files matching these globs will be scanned. See [here](https://github.com/micromatch/picomatch#globbing-features) for glob syntax.
Default: `["**/!(*.test\|*.spec).@(js\|ts)?(x)"]` |
244 | | `components` | object | Components to report. Omit to report all components. |
245 | | `includeSubComponents` | boolean | Whether to report subcomponents or not.
When `false`, `Footer` will be reported, but `Footer.Content` will not.
When `true`, `Footer.Content` will be reported, as well as `Footer.Content.Legal`, etc.
Default: `false` |
246 | | `importedFrom` | string or regex | Before reporting a component, we'll check if it's imported from a module name matching `importedFrom` and, only if there is a match, the component will be reported.
When omitted, this check is bypassed. |
247 | | `getComponentName` | function | This function is called to determine the component name to be used in the report based on the `import` declaration.
Default: `({ imported, local, moduleName, importType }) => imported \|\| local` |
248 | | `getPropValue` | function | Customize reporting for non-trivial prop values. See [Customizing prop values treatment](#customizing-prop-values-treatment) |
249 | | `processors` | array | See [Processors](#processors).
Default: `["count-components-and-props"]` |
250 |
251 | ## Processors
252 |
253 | Scanning the files results in a JSON report. Add processors to tell `react-scanner` what to do with this report.
254 |
255 | ### Built-in processors
256 |
257 | `react-scanner` comes with some ready to use processors.
258 |
259 | To use a built-in processor, simply specify its name as a string, e.g.:
260 |
261 | ```
262 | processors: ["count-components"]
263 | ```
264 |
265 | You can also use a tuple form to pass options to a built-in processor, e.g.:
266 |
267 | ```
268 | processors: [
269 | ["count-components", { outputTo: "/path/to/my-report.json" }]
270 | ]
271 | ```
272 |
273 | All the built-in processors support the following options:
274 |
275 | | Option | Type | Description |
276 | | ---------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- |
277 | | `outputTo` | string | Where to output the result.
Absolute or relative to the root directory.
When omitted, the result is printed out to the console. |
278 |
279 | Here are the built-in processors that `react-scanner` comes with:
280 |
281 | #### `count-components`
282 |
283 | Example output:
284 |
285 | ```json
286 | {
287 | "Text": 10,
288 | "Button": 5,
289 | "Link": 3
290 | }
291 | ```
292 |
293 | #### `count-components-and-props`
294 |
295 | Example output:
296 |
297 | ```json
298 | {
299 | "Text": {
300 | "instances": 17,
301 | "props": {
302 | "margin": 6,
303 | "color": 4,
304 | "textStyle": 1
305 | }
306 | },
307 | "Button": {
308 | "instances": 10,
309 | "props": {
310 | "width": 10,
311 | "variant": 4,
312 | "type": 2
313 | }
314 | },
315 | "Footer": {
316 | "instances": 1,
317 | "props": {}
318 | }
319 | }
320 | ```
321 |
322 | #### `raw-report`
323 |
324 | Example output:
325 |
326 | ```json
327 | {
328 | "Text": {
329 | "instances": [
330 | {
331 | "props": {
332 | "textStyle": "subtitle2"
333 | },
334 | "propsSpread": false,
335 | "location": {
336 | "file": "/path/to/file",
337 | "start": {
338 | "line": 9,
339 | "column": 9
340 | }
341 | }
342 | },
343 | {
344 | "props": {
345 | "margin": "4 0 0 0"
346 | },
347 | "propsSpread": false,
348 | "location": {
349 | "file": "/path/to/file",
350 | "start": {
351 | "line": 12,
352 | "column": 9
353 | }
354 | }
355 | }
356 | ]
357 | },
358 | "Link": {
359 | "instances": [
360 | {
361 | "props": {
362 | "href": "https://github.com/moroshko/react-scanner",
363 | "newTab": null
364 | },
365 | "propsSpread": false,
366 | "location": {
367 | "file": "/path/to/file",
368 | "start": {
369 | "line": 14,
370 | "column": 11
371 | }
372 | }
373 | }
374 | ]
375 | },
376 | "Container": {
377 | "instances": [
378 | {
379 | "props": {
380 | "margin": "4",
381 | "hasBreakpointWidth": null
382 | },
383 | "propsSpread": false,
384 | "location": {
385 | "file": "/path/to/file",
386 | "start": {
387 | "line": 8,
388 | "column": 7
389 | }
390 | }
391 | }
392 | ]
393 | }
394 | }
395 | ```
396 |
397 | ### Custom processors
398 |
399 | We saw above that built-in processors come in the form of a string or a tuple.
400 |
401 | Custom processors are functions, and can be asynchronous!
402 |
403 | If the processor function returns a `Promise`, it will be awaited before the next processor kicks in. This way, you can use previous processors results in your processor function.
404 |
405 | Here is an example of taking the output of the built-in `count-components-and-props` processor and sending it to your storage solution.
406 |
407 | ```
408 | processors: [
409 | "count-components-and-props",
410 | ({ prevResult }) => {
411 | return axios.post("/my/storage/solution", prevResult);
412 | }
413 | ]
414 | ```
415 |
416 | Processor functions receive an object with the following keys in it:
417 |
418 | | Key | Type | Description |
419 | | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
420 | | `report` | object | The raw JSON report. |
421 | | `prevResults` | array | Previous processors results. |
422 | | `prevResult` | any | The last item in `prevResults`. Just for convenience. |
423 | | `forEachComponent` | function | Helper function to recursively traverse the raw JSON report. The function you pass in is called for every component in the report, and it gets an object with `componentName` and `component` in it. Check the implementation of `count-components-and-props` for a usage example. |
424 | | `sortObjectKeysByValue` | function | Helper function that sorts object keys by some function of the value. Check the implementation of `count-components-and-props` for a usage example. |
425 | | `output` | function | Helper function that outputs the given data. Its first parameter is the data you want to output. The second parameter is the destination. When the second parameter is omitted, it outputs to the console. To output to the file system, pass an absolute path or a relative path to the config file location. |
426 |
427 | ## Customizing prop values treatment
428 |
429 | When a primitive (strings, numbers, booleans, etc...) is passed as a prop value into a component, the raw report will display this literal value. However, when expressions or variables are passed as a prop value into a component, the raw report will display the AST type. In some instances, we may want to see the actual expression that was passed in.
430 |
431 | ### getPropValue
432 |
433 | Using the `getPropValue` configuration parameter makes this possible.
434 |
435 | ```typescript
436 | type IGetPropValue = {
437 | /** The AST node */
438 | node: Node,
439 | componentName: string,
440 | propName: string,
441 | /** Pass the node back into this method for default handling of the prop value */
442 | defaultGetPropValue: (node: Node) => string
443 | }
444 | getPropValue({ node, componentName, propName, defaultGetPropValue }: IGetPropValue): string
445 | ```
446 |
447 | ### Example
448 |
449 | If we were building out a design system, and wanted to see all the variations of a `style` prop that we passed into an `Input` component, we could do something like this:
450 |
451 | ```javascript
452 | const escodegen = require("escodegen-wallaby");
453 |
454 | getPropValue: ({ node, propName, componentName, defaultGetPropValue }) => {
455 | if (componentName === "Input" && propName === "style") {
456 | if (node.type === "JSXExpressionContainer") {
457 | return escodegen.generate(node.expression);
458 | } else {
459 | return escodegen.generate(node);
460 | }
461 | } else {
462 | return defaultGetPropValue(node);
463 | }
464 | };
465 | ```
466 |
467 | ## License
468 |
469 | MIT
470 |
--------------------------------------------------------------------------------
/bin/react-scanner:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | require('../src/index');
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-scanner",
3 | "version": "1.2.0",
4 | "description": "Extract React components and props usage from code.",
5 | "bin": "bin/react-scanner",
6 | "main": "src/scanner.js",
7 | "scripts": {
8 | "prepare": "husky install",
9 | "build": "node scripts/processors.js",
10 | "lint": "eslint --max-warnings 0 \"**/*.js\"",
11 | "prettier": "prettier --write \"**/*.{js,json,md}\"",
12 | "test": "uvu src test",
13 | "test:watch": "watchlist src -- npm t",
14 | "test:coverage": "c8 --include=src/**/*.js -o coverage --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 npm t",
15 | "prepublishOnly": "npm run build"
16 | },
17 | "dependencies": {
18 | "@typescript-eslint/typescript-estree": "8.8.0",
19 | "astray": "1.1.1",
20 | "dlv": "1.1.3",
21 | "dset": "3.1.4",
22 | "fdir": "5.2.0",
23 | "is-plain-object": "5.0.0",
24 | "picomatch": "2.3.1",
25 | "sade": "1.8.1",
26 | "typescript": "5.6.2"
27 | },
28 | "devDependencies": {
29 | "c8": "7.12.0",
30 | "escodegen-wallaby": "1.6.44",
31 | "eslint": "8.23.1",
32 | "eslint-plugin-import": "2.31.0",
33 | "execa": "5.0.0",
34 | "husky": "7.0.4",
35 | "prettier": "2.7.1",
36 | "uvu": "0.5.6",
37 | "watchlist": "0.3.1"
38 | },
39 | "repository": {
40 | "type": "git",
41 | "url": "https://github.com/moroshko/react-scanner"
42 | },
43 | "files": [
44 | "bin",
45 | "src/**/!(*.test).@(js|json)"
46 | ],
47 | "engines": {
48 | "node": ">=14.x"
49 | },
50 | "keywords": [
51 | "react",
52 | "scanner",
53 | "component",
54 | "components",
55 | "jsx",
56 | "usage",
57 | "info",
58 | "stats",
59 | "statistics"
60 | ],
61 | "author": {
62 | "name": "Misha Moroshko",
63 | "email": "michael.moroshko@gmail.com"
64 | },
65 | "license": "MIT"
66 | }
67 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["config:base"],
3 | "schedule": "every weekend",
4 | "packageRules": [
5 | {
6 | "depTypeList": ["dependencies", "devDependencies"],
7 | "updateTypes": ["minor", "patch"],
8 | "rangeStrategy": "pin",
9 | "groupName": "minor and patch"
10 | },
11 | {
12 | "depTypeList": ["dependencies", "devDependencies"],
13 | "updateTypes": ["major"],
14 | "rangeStrategy": "pin",
15 | "dependencyDashboardApproval": true,
16 | "dependencyDashboardAutoclose": true
17 | }
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/scripts/processors.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const { fdir } = require("fdir");
4 |
5 | const processorsMap = new fdir()
6 | .glob(["**/!(*.test).js"])
7 | .crawl(path.resolve(__dirname, "../src/processors"))
8 | .sync()
9 | .map((file) => path.parse(file).name)
10 | .sort()
11 | .reduce((acc, processor) => {
12 | acc[processor] = true;
13 | return acc;
14 | }, {});
15 |
16 | fs.writeFileSync(
17 | path.resolve(__dirname, "../src/processors/processors.json"),
18 | JSON.stringify(processorsMap, null, 2) + "\n"
19 | );
20 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const sade = require("sade");
3 | const { run } = require("./scanner");
4 | const packageJson = require("../package.json");
5 |
6 | sade("react-scanner", true)
7 | .version(packageJson.version)
8 | .describe(packageJson.description)
9 | .option("-c, --config", "Path to config file")
10 | .example("-c /path/to/react-scanner.config.js")
11 | .action((options) => {
12 | const configPath = path.resolve(process.cwd(), options.config);
13 | const configDir = path.dirname(configPath);
14 | const config = require(configPath);
15 | run(config, configDir, "cli");
16 | })
17 | .parse(process.argv);
18 |
--------------------------------------------------------------------------------
/src/index.test.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const execa = require("execa");
4 | const { suite } = require("uvu");
5 | const assert = require("uvu/assert");
6 |
7 | const Index = suite("index");
8 |
9 | function parseStdout(stdout) {
10 | const firstLineBreakIndex = stdout.indexOf("\n");
11 |
12 | if (firstLineBreakIndex === -1) {
13 | return {
14 | firstLine: stdout,
15 | restOutput: null,
16 | };
17 | }
18 |
19 | return {
20 | firstLine: stdout.slice(0, firstLineBreakIndex),
21 | restOutput: stdout.slice(firstLineBreakIndex + 1),
22 | };
23 | }
24 |
25 | Index("no processors", async () => {
26 | const { exitCode, stdout } = await execa("./bin/react-scanner", [
27 | "-c",
28 | "./test/configs/noProcessors.config.js",
29 | ]);
30 | const { firstLine, restOutput } = parseStdout(stdout);
31 |
32 | assert.is(exitCode, 0);
33 | assert.ok(/^Scanned 2 files in [\d.]+ seconds$/.test(firstLine));
34 | assert.snapshot(
35 | restOutput,
36 | JSON.stringify(
37 | {
38 | Text: {
39 | instances: 2,
40 | props: {
41 | margin: 1,
42 | textStyle: 1,
43 | },
44 | },
45 | App: {
46 | instances: 1,
47 | props: {},
48 | },
49 | BasisProvider: {
50 | instances: 1,
51 | props: {
52 | theme: 1,
53 | },
54 | },
55 | Home: {
56 | instances: 1,
57 | props: {},
58 | },
59 | Link: {
60 | instances: 1,
61 | props: {
62 | href: 1,
63 | newTab: 1,
64 | },
65 | },
66 | div: {
67 | instances: 1,
68 | props: {
69 | style: 1,
70 | },
71 | },
72 | },
73 | null,
74 | 2
75 | )
76 | );
77 | });
78 |
79 | Index("single processor", async () => {
80 | const { exitCode, stdout } = await execa("./bin/react-scanner", [
81 | "-c",
82 | "./test/configs/singleProcessor.config.js",
83 | ]);
84 | const { firstLine } = parseStdout(stdout);
85 | const reportPath = path.resolve(
86 | __dirname,
87 | "../test/reports/singleProcessor.json"
88 | );
89 | const report = fs.readFileSync(reportPath, "utf8");
90 |
91 | assert.is(exitCode, 0);
92 | assert.ok(/^Scanned 2 files in [\d.]+ seconds$/.test(firstLine));
93 | assert.snapshot(
94 | report,
95 | JSON.stringify(
96 | {
97 | Text: 2,
98 | App: 1,
99 | BasisProvider: 1,
100 | Home: 1,
101 | Link: 1,
102 | div: 1,
103 | },
104 | null,
105 | 2
106 | )
107 | );
108 | });
109 |
110 | Index("multiple processors", async () => {
111 | const { exitCode, stdout } = await execa("./bin/react-scanner", [
112 | "-c",
113 | "./test/configs/multipleProcessors.config.js",
114 | ]);
115 | const { firstLine, restOutput } = parseStdout(stdout);
116 | const countComponentsAndPropsReportPath = path.resolve(
117 | __dirname,
118 | "../test/reports/multipleProcessors-countComponentsAndProps.json"
119 | );
120 | const customReportPath = path.resolve(
121 | __dirname,
122 | "../test/reports/multipleProcessors-custom.txt"
123 | );
124 | const countComponentsAndPropsReport = fs.readFileSync(
125 | countComponentsAndPropsReportPath,
126 | "utf8"
127 | );
128 | const customReport = fs.readFileSync(customReportPath, "utf8");
129 |
130 | assert.is(exitCode, 0);
131 | assert.ok(/^Scanned 2 files in [\d.]+ seconds$/.test(firstLine));
132 | assert.snapshot(
133 | restOutput,
134 | JSON.stringify(
135 | {
136 | Text: 2,
137 | BasisProvider: 1,
138 | Link: 1,
139 | "React.Fragment": 1,
140 | },
141 | null,
142 | 2
143 | )
144 | );
145 | assert.snapshot(
146 | countComponentsAndPropsReport,
147 | JSON.stringify(
148 | {
149 | Text: {
150 | instances: 2,
151 | props: {
152 | margin: 1,
153 | textStyle: 1,
154 | },
155 | },
156 | BasisProvider: {
157 | instances: 1,
158 | props: {
159 | theme: 1,
160 | },
161 | },
162 | Link: {
163 | instances: 1,
164 | props: {
165 | href: 1,
166 | newTab: 1,
167 | },
168 | },
169 | "React.Fragment": {
170 | instances: 1,
171 | props: {},
172 | },
173 | },
174 | null,
175 | 2
176 | )
177 | );
178 | assert.is(customReport, "something");
179 | });
180 |
181 | Index("invalid config", async () => {
182 | try {
183 | await execa("./bin/react-scanner", [
184 | "-c",
185 | "./test/configs/invalid.config.js",
186 | ]);
187 | } catch ({ exitCode, stderr }) {
188 | assert.is(exitCode, 1);
189 | assert.is(
190 | stderr,
191 | [
192 | "Config errors:",
193 | "- crawlFrom should be a string",
194 | "- exclude should be an array or a function",
195 | ].join("\n")
196 | );
197 | }
198 | });
199 |
200 | Index("no files found", async () => {
201 | try {
202 | await execa("./bin/react-scanner", [
203 | "-c",
204 | "./test/configs/noFilesFound.config.js",
205 | ]);
206 |
207 | assert.unreachable("should have thrown");
208 | } catch (err) {
209 | assert.instance(err, Error);
210 | assert.match(err.message, "No files found to scan");
211 | }
212 | });
213 |
214 | Index.run();
215 |
--------------------------------------------------------------------------------
/src/processors/count-components-and-props.js:
--------------------------------------------------------------------------------
1 | const countComponentsAndPropsProcessor =
2 | (options) =>
3 | ({ forEachComponent, sortObjectKeysByValue, output }) => {
4 | let result = {};
5 |
6 | forEachComponent(({ componentName, component }) => {
7 | const { instances } = component;
8 |
9 | if (!instances) {
10 | return;
11 | }
12 |
13 | result[componentName] = {
14 | instances: instances.length,
15 | props: {},
16 | };
17 |
18 | instances.forEach((instance) => {
19 | for (const prop in instance.props) {
20 | if (result[componentName].props[prop] === undefined) {
21 | result[componentName].props[prop] = 0;
22 | }
23 |
24 | result[componentName].props[prop] += 1;
25 | }
26 | });
27 |
28 | result[componentName].props = sortObjectKeysByValue(
29 | result[componentName].props
30 | );
31 | });
32 |
33 | result = sortObjectKeysByValue(result, (component) => component.instances);
34 |
35 | output(result, options && options.outputTo);
36 |
37 | return result;
38 | };
39 |
40 | module.exports = countComponentsAndPropsProcessor;
41 |
--------------------------------------------------------------------------------
/src/processors/count-components.js:
--------------------------------------------------------------------------------
1 | const countComponentsProcessor =
2 | (options) =>
3 | ({ forEachComponent, sortObjectKeysByValue, output }) => {
4 | let result = {};
5 |
6 | forEachComponent(({ componentName, component }) => {
7 | const { instances } = component;
8 |
9 | if (instances) {
10 | result[componentName] = instances.length;
11 | }
12 | });
13 |
14 | result = sortObjectKeysByValue(result);
15 |
16 | output(result, options && options.outputTo);
17 |
18 | return result;
19 | };
20 |
21 | module.exports = countComponentsProcessor;
22 |
--------------------------------------------------------------------------------
/src/processors/processors.json:
--------------------------------------------------------------------------------
1 | {
2 | "count-components": true,
3 | "count-components-and-props": true,
4 | "raw-report": true
5 | }
6 |
--------------------------------------------------------------------------------
/src/processors/raw-report.js:
--------------------------------------------------------------------------------
1 | const rawReportProcessor =
2 | (options) =>
3 | ({ report, output }) => {
4 | output(report, options && options.outputTo);
5 |
6 | return report;
7 | };
8 |
9 | module.exports = rawReportProcessor;
10 |
--------------------------------------------------------------------------------
/src/run.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const { fdir } = require("fdir");
4 | const { isPlainObject } = require("is-plain-object");
5 | const scan = require("./scan");
6 | const {
7 | pluralize,
8 | forEachComponent,
9 | sortObjectKeysByValue,
10 | getExcludeFn,
11 | } = require("./utils");
12 |
13 | const DEFAULT_GLOBS = ["**/!(*.test|*.spec).@(js|ts)?(x)"];
14 | const DEFAULT_PROCESSORS = ["count-components-and-props"];
15 |
16 | async function run({
17 | config,
18 | configDir,
19 | crawlFrom,
20 | startTime,
21 | method = "cli",
22 | }) {
23 | const rootDir = config.rootDir || configDir;
24 | const globs = config.globs || DEFAULT_GLOBS;
25 | const files = new fdir()
26 | .glob(...globs)
27 | .exclude(getExcludeFn(config.exclude))
28 | .withFullPaths()
29 | .crawl(crawlFrom)
30 | .sync();
31 |
32 | if (files.length === 0) {
33 | console.error(`No files found to scan.`);
34 | throw new Error(`No files found to scan.`);
35 | }
36 |
37 | let report = {};
38 | const {
39 | components,
40 | includeSubComponents,
41 | importedFrom,
42 | getComponentName,
43 | getPropValue,
44 | } = config;
45 |
46 | for (let i = 0, len = files.length; i < len; i++) {
47 | const filePath = files[i];
48 | const code = fs.readFileSync(filePath, "utf8");
49 |
50 | scan({
51 | code,
52 | filePath,
53 | components,
54 | includeSubComponents,
55 | importedFrom,
56 | getComponentName,
57 | report,
58 | getPropValue,
59 | });
60 | }
61 |
62 | const endTime = process.hrtime.bigint();
63 |
64 | // eslint-disable-next-line no-console
65 | console.log(
66 | `Scanned ${pluralize(files.length, "file")} in ${
67 | Number(endTime - startTime) / 1e9
68 | } seconds`
69 | );
70 |
71 | const processors =
72 | config.processors && config.processors.length > 0
73 | ? config.processors
74 | : DEFAULT_PROCESSORS;
75 | const prevResults = [];
76 | const output = (data, destination) => {
77 | const defaultDestination = method === "cli" ? "stdout" : "return";
78 | const dest = destination || defaultDestination;
79 | const dataStr = isPlainObject(data)
80 | ? JSON.stringify(data, null, 2)
81 | : String(data);
82 |
83 | switch (dest) {
84 | case "stdout": {
85 | // eslint-disable-next-line no-console
86 | console.log(dataStr);
87 | break;
88 | }
89 | case "return": {
90 | break;
91 | }
92 | default: {
93 | const filePath = path.resolve(rootDir, destination);
94 |
95 | fs.mkdirSync(path.dirname(filePath), { recursive: true });
96 | fs.writeFileSync(filePath, dataStr);
97 | }
98 | }
99 | };
100 |
101 | for (const processor of processors) {
102 | let processorFn;
103 |
104 | if (typeof processor === "string") {
105 | processorFn = require(`./processors/${processor}`)();
106 | } else if (Array.isArray(processor)) {
107 | processorFn = require(`./processors/${processor[0]}`)(processor[1]);
108 | } else if (typeof processor === "function") {
109 | processorFn = processor;
110 | }
111 |
112 | const result = await processorFn({
113 | report,
114 | prevResults,
115 | prevResult: prevResults[prevResults.length - 1],
116 | forEachComponent: forEachComponent(report),
117 | sortObjectKeysByValue,
118 | output,
119 | });
120 |
121 | prevResults.push(result);
122 | }
123 |
124 | return prevResults[prevResults.length - 1];
125 | }
126 |
127 | module.exports = run;
128 |
--------------------------------------------------------------------------------
/src/scan.js:
--------------------------------------------------------------------------------
1 | const { parse } = require("@typescript-eslint/typescript-estree");
2 | const astray = require("astray");
3 | const getObjectPath = require("dlv");
4 | const { dset } = require("dset");
5 |
6 | const parseOptions = {
7 | loc: true,
8 | jsx: true,
9 | };
10 |
11 | function getComponentNameFromAST(nameObj) {
12 | switch (nameObj.type) {
13 | case "JSXIdentifier": {
14 | return nameObj.name;
15 | }
16 |
17 | case "JSXMemberExpression": {
18 | return `${getComponentNameFromAST(
19 | nameObj.object
20 | )}.${getComponentNameFromAST(nameObj.property)}`;
21 | }
22 |
23 | /* c8 ignore next 3 */
24 | default: {
25 | throw new Error(`Unknown name type: ${nameObj.type}`);
26 | }
27 | }
28 | }
29 |
30 | function getPropValue(node) {
31 | if (node === null) {
32 | return null;
33 | }
34 |
35 | if (node.type === "Literal") {
36 | return node.value;
37 | }
38 |
39 | if (node.type === "JSXExpressionContainer") {
40 | if (node.expression.type === "Literal") {
41 | return node.expression.value;
42 | }
43 |
44 | return `(${node.expression.type})`;
45 | /* c8 ignore next 3 */
46 | }
47 |
48 | throw new Error(`Unknown node type: ${node.type}`);
49 | }
50 |
51 | function getInstanceInfo({
52 | node,
53 | filePath,
54 | importInfo,
55 | getPropValue: customGetPropValue,
56 | componentName,
57 | }) {
58 | const { attributes } = node;
59 | const result = {
60 | ...(importInfo !== undefined && { importInfo }),
61 | props: {},
62 | propsSpread: false,
63 | location: {
64 | file: filePath,
65 | start: node.name.loc.start,
66 | },
67 | };
68 |
69 | for (let i = 0, len = attributes.length; i < len; i++) {
70 | const attribute = attributes[i];
71 |
72 | if (attribute.type === "JSXAttribute") {
73 | const { name, value } = attribute;
74 | const propName = name.name;
75 | const propValue = customGetPropValue
76 | ? customGetPropValue({
77 | node: value,
78 | propName,
79 | componentName,
80 | defaultGetPropValue: getPropValue,
81 | })
82 | : getPropValue(value);
83 |
84 | result.props[propName] = propValue;
85 | } else if (attribute.type === "JSXSpreadAttribute") {
86 | result.propsSpread = true;
87 | }
88 | }
89 |
90 | return result;
91 | }
92 |
93 | function scan({
94 | code,
95 | filePath,
96 | components,
97 | includeSubComponents = false,
98 | importedFrom,
99 | getComponentName = ({ imported, local }) =>
100 | imported === "default" ? local : imported || local,
101 | report,
102 | getPropValue,
103 | }) {
104 | let ast;
105 |
106 | try {
107 | ast = parse(code, parseOptions);
108 | } catch (_e) {
109 | console.error(`Failed to parse: ${filePath}`);
110 | return;
111 | }
112 |
113 | const importsMap = {};
114 |
115 | astray.walk(ast, {
116 | ImportDeclaration(node) {
117 | const { source, specifiers } = node;
118 | const moduleName = source.value;
119 | const specifiersCount = specifiers.length;
120 |
121 | for (let i = 0; i < specifiersCount; i++) {
122 | switch (specifiers[i].type) {
123 | case "ImportDefaultSpecifier":
124 | case "ImportSpecifier":
125 | case "ImportNamespaceSpecifier": {
126 | const imported = specifiers[i].imported
127 | ? specifiers[i].imported.name
128 | : null;
129 | const local = specifiers[i].local.name;
130 |
131 | importsMap[local] = {
132 | ...(imported !== null && { imported }),
133 | local,
134 | moduleName,
135 | importType: specifiers[i].type,
136 | };
137 | break;
138 | }
139 |
140 | /* c8 ignore next 5 */
141 | default: {
142 | throw new Error(
143 | `Unknown import specifier type: ${specifiers[i].type}`
144 | );
145 | }
146 | }
147 | }
148 | },
149 | JSXOpeningElement: {
150 | exit(node) {
151 | const name = getComponentNameFromAST(node.name);
152 | const nameParts = name.split(".");
153 | const [firstPart, ...restParts] = nameParts;
154 | const actualFirstPart = importsMap[firstPart]
155 | ? getComponentName(importsMap[firstPart])
156 | : firstPart;
157 | const shouldReportComponent = () => {
158 | if (components) {
159 | if (nameParts.length === 1) {
160 | if (components[actualFirstPart] === undefined) {
161 | return false;
162 | }
163 | } else {
164 | const actualComponentName = [actualFirstPart, ...restParts].join(
165 | "."
166 | );
167 |
168 | if (
169 | components[actualFirstPart] === undefined &&
170 | components[actualComponentName] === undefined
171 | ) {
172 | return false;
173 | }
174 | }
175 | }
176 |
177 | if (includeSubComponents === false) {
178 | if (nameParts.length > 1) {
179 | return false;
180 | }
181 | }
182 |
183 | if (importedFrom) {
184 | if (!importsMap[firstPart]) {
185 | return false;
186 | }
187 |
188 | const actualImportedFrom = importsMap[firstPart].moduleName;
189 |
190 | if (importedFrom instanceof RegExp) {
191 | if (importedFrom.test(actualImportedFrom) === false) {
192 | return false;
193 | }
194 | } else if (actualImportedFrom !== importedFrom) {
195 | return false;
196 | }
197 | }
198 |
199 | return true;
200 | };
201 |
202 | if (!shouldReportComponent()) {
203 | return astray.SKIP;
204 | }
205 |
206 | const componentParts = [actualFirstPart, ...restParts];
207 |
208 | const componentPath = componentParts.join(".components.");
209 | const componentName = componentParts.join(".");
210 | let componentInfo = getObjectPath(report, componentPath);
211 |
212 | if (!componentInfo) {
213 | componentInfo = {};
214 | dset(report, componentPath, componentInfo);
215 | }
216 |
217 | if (!componentInfo.instances) {
218 | componentInfo.instances = [];
219 | }
220 |
221 | const info = getInstanceInfo({
222 | node,
223 | filePath,
224 | importInfo: importsMap[firstPart],
225 | getPropValue,
226 | componentName,
227 | });
228 |
229 | componentInfo.instances.push(info);
230 | },
231 | },
232 | });
233 | }
234 |
235 | module.exports = scan;
236 |
--------------------------------------------------------------------------------
/src/scan.test.js:
--------------------------------------------------------------------------------
1 | const { suite } = require("uvu");
2 | const escodegen = require("escodegen-wallaby");
3 | const assert = require("uvu/assert");
4 | const scan = require("./scan");
5 |
6 | const Scan = suite("scan");
7 |
8 | Scan.before((context) => {
9 | context.getReport = (
10 | filePath,
11 | code,
12 | {
13 | components,
14 | includeSubComponents,
15 | importedFrom,
16 | getComponentName,
17 | getPropValue,
18 | } = {}
19 | ) => {
20 | const report = {};
21 |
22 | scan({
23 | code,
24 | filePath,
25 | components: {
26 | Box: true,
27 | Header: true,
28 | Text: true,
29 | Input: true,
30 | },
31 | ...(components !== undefined && { components }),
32 | ...(includeSubComponents !== undefined && { includeSubComponents }),
33 | ...(importedFrom !== undefined && { importedFrom }),
34 | ...(getComponentName !== undefined && { getComponentName }),
35 | ...(getPropValue !== undefined && { getPropValue }),
36 | report,
37 | });
38 |
39 | return report;
40 | };
41 | });
42 |
43 | Scan.after((context) => {
44 | context.getReport = undefined;
45 | });
46 |
47 | Scan("invalid code", ({ getReport }) => {
48 | const originalConsoleError = global.console.error;
49 | let errors = [];
50 |
51 | global.console.error = (...args) => {
52 | errors = errors.concat(args);
53 | };
54 |
55 | const report = getReport("invalid-code.js", ` {
64 | const report = getReport(
65 | "unknown-components.js",
66 | `
67 |
68 |
69 |
70 |
71 | `
72 | );
73 |
74 | assert.equal(report, {});
75 | });
76 |
77 | Scan("ignores comments", ({ getReport }) => {
78 | const report = getReport("ignores-comments.js", `{/* Hello */}`);
79 |
80 | assert.equal(report, {});
81 | });
82 |
83 | Scan("self closing", ({ getReport }) => {
84 | const report = getReport("self-closing.js", ``);
85 |
86 | assert.equal(report, {
87 | Header: {
88 | instances: [
89 | {
90 | props: {},
91 | propsSpread: false,
92 | location: {
93 | file: "self-closing.js",
94 | start: {
95 | line: 1,
96 | column: 1,
97 | },
98 | },
99 | },
100 | ],
101 | },
102 | });
103 | });
104 |
105 | Scan("no props", ({ getReport }) => {
106 | const report = getReport("no-props.js", `Hello`);
107 |
108 | assert.equal(report, {
109 | Text: {
110 | instances: [
111 | {
112 | props: {},
113 | propsSpread: false,
114 | location: {
115 | file: "no-props.js",
116 | start: {
117 | line: 1,
118 | column: 1,
119 | },
120 | },
121 | },
122 | ],
123 | },
124 | });
125 | });
126 |
127 | Scan("prop with no value", ({ getReport }) => {
128 | const report = getReport(
129 | "prop-with-no-value.js",
130 | `Hello`
131 | );
132 |
133 | assert.equal(report, {
134 | Text: {
135 | instances: [
136 | {
137 | props: {
138 | foo: null,
139 | bar: true,
140 | },
141 | propsSpread: false,
142 | location: {
143 | file: "prop-with-no-value.js",
144 | start: {
145 | line: 1,
146 | column: 1,
147 | },
148 | },
149 | },
150 | ],
151 | },
152 | });
153 | });
154 |
155 | Scan("props with literal values", ({ getReport }) => {
156 | const report = getReport(
157 | "props-with-literal-values.js",
158 | `Hello`
159 | );
160 |
161 | assert.equal(report, {
162 | Text: {
163 | instances: [
164 | {
165 | props: {
166 | textStyle: "heading2",
167 | wrap: false,
168 | columns: 3,
169 | },
170 | propsSpread: false,
171 | location: {
172 | file: "props-with-literal-values.js",
173 | start: {
174 | line: 1,
175 | column: 1,
176 | },
177 | },
178 | },
179 | ],
180 | },
181 | });
182 | });
183 |
184 | Scan("props with other values", ({ getReport }) => {
185 | const report = getReport(
186 | "props-with-other-values.js",
187 | `Hello`
188 | );
189 |
190 | assert.equal(report, {
191 | Text: {
192 | instances: [
193 | {
194 | props: {
195 | foo: "(Identifier)",
196 | style: "(ObjectExpression)",
197 | },
198 | propsSpread: false,
199 | location: {
200 | file: "props-with-other-values.js",
201 | start: {
202 | line: 1,
203 | column: 1,
204 | },
205 | },
206 | },
207 | ],
208 | },
209 | });
210 | });
211 |
212 | Scan("props with custom value formatter", ({ getReport }) => {
213 | const report = getReport(
214 | "props-with-custom-value-formatter.js",
215 | `<>
216 | e.preventDefault()}/>
217 |
218 | >`,
219 | {
220 | getPropValue: ({
221 | node,
222 | propName,
223 | componentName,
224 | defaultGetPropValue,
225 | }) => {
226 | if (componentName === "Input" && propName === "style") {
227 | if (node.type === "JSXExpressionContainer") {
228 | return escodegen.generate(node.expression);
229 | } else {
230 | return escodegen.generate(node);
231 | }
232 | } else {
233 | return defaultGetPropValue(node);
234 | }
235 | },
236 | }
237 | );
238 |
239 | assert.equal(report, {
240 | Input: {
241 | instances: [
242 | {
243 | props: {
244 | style: "{ fontSize: '10px' }",
245 | onClick: "(ArrowFunctionExpression)",
246 | },
247 | propsSpread: false,
248 | location: {
249 | file: "props-with-custom-value-formatter.js",
250 | start: {
251 | line: 2,
252 | column: 9,
253 | },
254 | },
255 | },
256 | {
257 | props: {
258 | style: "{ padding: '10px' }",
259 | value: "(Identifier)",
260 | },
261 | propsSpread: false,
262 | location: {
263 | file: "props-with-custom-value-formatter.js",
264 | start: {
265 | line: 3,
266 | column: 9,
267 | },
268 | },
269 | },
270 | ],
271 | },
272 | });
273 | });
274 |
275 | Scan("with props spread", ({ getReport }) => {
276 | const report = getReport(
277 | "with-props-spread.js",
278 | `Hello`
279 | );
280 |
281 | assert.equal(report, {
282 | Text: {
283 | instances: [
284 | {
285 | props: {},
286 | propsSpread: true,
287 | location: {
288 | file: "with-props-spread.js",
289 | start: {
290 | line: 1,
291 | column: 1,
292 | },
293 | },
294 | },
295 | ],
296 | },
297 | });
298 | });
299 |
300 | Scan("no sub components by default", ({ getReport }) => {
301 | const report = getReport(
302 | "no-sub-components-by-default.js",
303 | `
304 |
307 | `
308 | );
309 |
310 | assert.equal(report, {
311 | Header: {
312 | instances: [
313 | {
314 | props: {},
315 | propsSpread: false,
316 | location: {
317 | file: "no-sub-components-by-default.js",
318 | start: {
319 | line: 2,
320 | column: 5,
321 | },
322 | },
323 | },
324 | ],
325 | },
326 | });
327 | });
328 |
329 | Scan("with sub components", ({ getReport }) => {
330 | const report = getReport(
331 | "with-sub-components.js",
332 | `
333 | <>
334 |
337 |
338 | >
339 | `,
340 | {
341 | components: {
342 | Header: true,
343 | "Footer.Legal": true,
344 | },
345 | includeSubComponents: true,
346 | }
347 | );
348 |
349 | assert.equal(report, {
350 | Header: {
351 | instances: [
352 | {
353 | props: {},
354 | propsSpread: false,
355 | location: {
356 | file: "with-sub-components.js",
357 | start: {
358 | line: 3,
359 | column: 7,
360 | },
361 | },
362 | },
363 | ],
364 | components: {
365 | Logo: {
366 | instances: [
367 | {
368 | props: {},
369 | propsSpread: false,
370 | location: {
371 | file: "with-sub-components.js",
372 | start: {
373 | line: 4,
374 | column: 9,
375 | },
376 | },
377 | },
378 | ],
379 | },
380 | },
381 | },
382 | Footer: {
383 | components: {
384 | Legal: {
385 | instances: [
386 | {
387 | props: {},
388 | propsSpread: false,
389 | location: {
390 | file: "with-sub-components.js",
391 | start: {
392 | line: 6,
393 | column: 7,
394 | },
395 | },
396 | },
397 | ],
398 | },
399 | },
400 | },
401 | });
402 | });
403 |
404 | Scan("deeply nested sub components", ({ getReport }) => {
405 | const report = getReport(
406 | "deeply-nested-sub-components.js",
407 | `
408 |
409 |
410 |
411 |
412 | Hello
413 |
414 | Title
415 |
416 |
417 |
418 |
419 | `,
420 | {
421 | includeSubComponents: true,
422 | }
423 | );
424 |
425 | assert.equal(report, {
426 | Header: {
427 | instances: [
428 | {
429 | props: {},
430 | propsSpread: false,
431 | location: {
432 | file: "deeply-nested-sub-components.js",
433 | start: {
434 | line: 2,
435 | column: 5,
436 | },
437 | },
438 | },
439 | ],
440 | components: {
441 | Logo: {
442 | instances: [
443 | {
444 | props: {
445 | name: "foo",
446 | },
447 | propsSpread: false,
448 | location: {
449 | file: "deeply-nested-sub-components.js",
450 | start: {
451 | line: 3,
452 | column: 7,
453 | },
454 | },
455 | },
456 | ],
457 | },
458 | Content: {
459 | instances: [
460 | {
461 | props: {},
462 | propsSpread: false,
463 | location: {
464 | file: "deeply-nested-sub-components.js",
465 | start: {
466 | line: 4,
467 | column: 7,
468 | },
469 | },
470 | },
471 | ],
472 | components: {
473 | Column: {
474 | instances: [
475 | {
476 | props: {},
477 | propsSpread: false,
478 | location: {
479 | file: "deeply-nested-sub-components.js",
480 | start: {
481 | line: 5,
482 | column: 9,
483 | },
484 | },
485 | },
486 | ],
487 | components: {
488 | Title: {
489 | instances: [
490 | {
491 | props: {
492 | variant: "important",
493 | },
494 | propsSpread: false,
495 | location: {
496 | file: "deeply-nested-sub-components.js",
497 | start: {
498 | line: 7,
499 | column: 11,
500 | },
501 | },
502 | },
503 | ],
504 | },
505 | },
506 | },
507 | },
508 | },
509 | },
510 | },
511 | });
512 | });
513 |
514 | Scan("ignores non-JSX stuff", ({ getReport }) => {
515 | const report = getReport(
516 | "ignores-non-jsx-stuff.js",
517 | `
518 | import React from "react";
519 |
520 | function GoodbyeMessage({ languageStyle }) {
521 | return languageStyle === "formal" ? (
522 | Goodbye
523 | ) : (
524 | See ya
525 | );
526 | }
527 |
528 | function App() {
529 | return (
530 |
531 | Hello
532 |
533 |
534 | )
535 | }
536 |
537 | export default App;
538 | `
539 | );
540 |
541 | assert.equal(report, {
542 | Text: {
543 | instances: [
544 | {
545 | props: {},
546 | propsSpread: false,
547 | location: {
548 | file: "ignores-non-jsx-stuff.js",
549 | start: {
550 | line: 6,
551 | column: 9,
552 | },
553 | },
554 | },
555 | {
556 | props: {},
557 | propsSpread: false,
558 | location: {
559 | file: "ignores-non-jsx-stuff.js",
560 | start: {
561 | line: 8,
562 | column: 9,
563 | },
564 | },
565 | },
566 | {
567 | props: {
568 | color: "blue",
569 | },
570 | propsSpread: false,
571 | location: {
572 | file: "ignores-non-jsx-stuff.js",
573 | start: {
574 | line: 15,
575 | column: 11,
576 | },
577 | },
578 | },
579 | ],
580 | },
581 | });
582 | });
583 |
584 | Scan("typescript", ({ getReport }) => {
585 | const report = getReport(
586 | "typescript.ts",
587 | `
588 | /* @jsx jsx */
589 | import { jsx } from "@emotion/core"; // eslint-disable-line
590 | import React, { ReactNode, ElementType, useContext } from "react"; // eslint-disable-line
591 | import { Box, BoxProps, useTheme } from "@chakra-ui/core";
592 | import capsize from "capsize";
593 | import siteFontContext from "./SiteProvider";
594 | import { FontMetrics } from "capsize";
595 | import fontSizes from "../fontSizes";
596 |
597 | export interface HeadingProps {
598 | children: ReactNode;
599 | as?: ElementType;
600 | size?: '1' | '2' | '3';
601 | align?: BoxProps['textAlign'];
602 | }
603 |
604 | const element = {
605 | '1': 'h1',
606 | '2': 'h2',
607 | '3': 'h3',
608 | } as const;
609 |
610 | const color = {
611 | '1': 'blue.900',
612 | '2': 'blue.800',
613 | '3': 'gray.500',
614 | };
615 | const capsizeForSize = (size: number, font: FontMetrics) =>
616 | capsize({
617 | capHeight: size,
618 | leading: Math.floor(size * 1.9),
619 | fontMetrics: font,
620 | });
621 |
622 | const Heading = ({ children, as, size = '1', align }: HeadingProps) => {
623 | const activeFont = useContext(siteFontContext);
624 | const theme = useTheme();
625 |
626 | const mq = (theme.breakpoints as string[])
627 | .slice(0, 4)
628 | .map((bp) => \`@media (min-width: \${bp})\`);
629 |
630 | return (
631 |
643 | {children}
644 |
645 | );
646 | };
647 |
648 | export default Heading;
649 | `
650 | );
651 |
652 | assert.equal(report, {
653 | Box: {
654 | instances: [
655 | {
656 | importInfo: {
657 | imported: "Box",
658 | local: "Box",
659 | moduleName: "@chakra-ui/core",
660 | importType: "ImportSpecifier",
661 | },
662 | props: {
663 | as: "(LogicalExpression)",
664 | fontFamily: "(MemberExpression)",
665 | color: "(MemberExpression)",
666 | textAlign: "(Identifier)",
667 | css: "(ObjectExpression)",
668 | },
669 | propsSpread: false,
670 | location: {
671 | file: "typescript.ts",
672 | start: {
673 | line: 45,
674 | column: 9,
675 | },
676 | },
677 | },
678 | ],
679 | },
680 | });
681 | });
682 |
683 | Scan("not importedFrom", ({ getReport }) => {
684 | const report = getReport(
685 | "not-imported-from.js",
686 | `
687 | import Header from "other-design-system";
688 |
689 |
690 | `,
691 | { importedFrom: "my-design-system" }
692 | );
693 |
694 | assert.equal(report, {});
695 | });
696 |
697 | Scan("importedFrom default export", ({ getReport }) => {
698 | const report = getReport(
699 | "imported-from-default-export.js",
700 | `
701 | import Header from "my-design-system";
702 | import Box from "other-module";
703 |
704 |
705 |
706 |
707 | `,
708 | { importedFrom: /my-design-system/ }
709 | );
710 |
711 | assert.equal(report, {
712 | Header: {
713 | instances: [
714 | {
715 | importInfo: {
716 | local: "Header",
717 | moduleName: "my-design-system",
718 | importType: "ImportDefaultSpecifier",
719 | },
720 | props: {},
721 | propsSpread: false,
722 | location: {
723 | file: "imported-from-default-export.js",
724 | start: {
725 | line: 6,
726 | column: 7,
727 | },
728 | },
729 | },
730 | ],
731 | },
732 | });
733 | });
734 |
735 | Scan("importedFrom default export as", ({ getReport }) => {
736 | const report = getReport(
737 | "imported-from-default-export.js",
738 | `
739 | import { default as Header } from "my-design-system";
740 | import Box from "other-module";
741 |
742 |
743 |
744 |
745 | `,
746 | { importedFrom: /my-design-system/ }
747 | );
748 |
749 | assert.equal(report, {
750 | Header: {
751 | instances: [
752 | {
753 | importInfo: {
754 | imported: "default",
755 | local: "Header",
756 | moduleName: "my-design-system",
757 | importType: "ImportSpecifier",
758 | },
759 | props: {},
760 | propsSpread: false,
761 | location: {
762 | file: "imported-from-default-export.js",
763 | start: {
764 | line: 6,
765 | column: 7,
766 | },
767 | },
768 | },
769 | ],
770 | },
771 | });
772 | });
773 |
774 | Scan("importedFrom named export", ({ getReport }) => {
775 | const report = getReport(
776 | "imported-from-named-export.js",
777 | `
778 | import { Header } from "basis";
779 |
780 |
781 | `,
782 | { importedFrom: "basis" }
783 | );
784 |
785 | assert.equal(report, {
786 | Header: {
787 | instances: [
788 | {
789 | importInfo: {
790 | imported: "Header",
791 | local: "Header",
792 | moduleName: "basis",
793 | importType: "ImportSpecifier",
794 | },
795 | props: {},
796 | propsSpread: false,
797 | location: {
798 | file: "imported-from-named-export.js",
799 | start: {
800 | line: 4,
801 | column: 5,
802 | },
803 | },
804 | },
805 | ],
806 | },
807 | });
808 | });
809 |
810 | Scan("props with jsx expressions", ({ getReport }) => {
811 | const report = getReport(
812 | "imported-from-in-prop-jsx.js",
813 |
814 | `
815 | import { Text } from "other-place";
816 | import { Box } from "basis";
817 |
818 | } />`,
819 | { importedFrom: "basis" }
820 | );
821 |
822 | assert.equal(report, {
823 | Box: {
824 | instances: [
825 | {
826 | props: {
827 | foo: "(Identifier)",
828 | },
829 | propsSpread: false,
830 | location: {
831 | file: "imported-from-in-prop-jsx.js",
832 | start: {
833 | line: 5,
834 | column: 16,
835 | },
836 | },
837 | importInfo: {
838 | imported: "Box",
839 | local: "Box",
840 | moduleName: "basis",
841 | importType: "ImportSpecifier",
842 | },
843 | },
844 | ],
845 | },
846 | });
847 | });
848 |
849 | Scan("importedFrom named export with alias", ({ getReport }) => {
850 | const report = getReport(
851 | "imported-from-named-export-with-alias.js",
852 | `
853 | import { Header as MyHeader } from "basis";
854 |
855 |
856 | `,
857 | { importedFrom: "basis" }
858 | );
859 |
860 | assert.equal(report, {
861 | Header: {
862 | instances: [
863 | {
864 | importInfo: {
865 | imported: "Header",
866 | local: "MyHeader",
867 | moduleName: "basis",
868 | importType: "ImportSpecifier",
869 | },
870 | props: {},
871 | propsSpread: false,
872 | location: {
873 | file: "imported-from-named-export-with-alias.js",
874 | start: {
875 | line: 4,
876 | column: 5,
877 | },
878 | },
879 | },
880 | ],
881 | },
882 | });
883 | });
884 |
885 | Scan(
886 | "importedFrom named export with alias - sub component",
887 | ({ getReport }) => {
888 | const report = getReport(
889 | "imported-from-named-export-with-alias-sub-component.js",
890 | `
891 | import { Header as MyHeader } from "basis";
892 |
893 | <>
894 |
895 |
896 |
897 | >
898 | `,
899 | {
900 | includeSubComponents: true,
901 | importedFrom: "basis",
902 | }
903 | );
904 |
905 | assert.equal(report, {
906 | Header: {
907 | components: {
908 | Foo: {
909 | components: {
910 | Bar: {
911 | instances: [
912 | {
913 | importInfo: {
914 | imported: "Header",
915 | local: "MyHeader",
916 | moduleName: "basis",
917 | importType: "ImportSpecifier",
918 | },
919 | props: {},
920 | propsSpread: false,
921 | location: {
922 | file: "imported-from-named-export-with-alias-sub-component.js",
923 | start: {
924 | line: 5,
925 | column: 7,
926 | },
927 | },
928 | },
929 | ],
930 | },
931 | },
932 | },
933 | },
934 | },
935 | });
936 | }
937 | );
938 |
939 | Scan("importedFrom entire module", ({ getReport }) => {
940 | const report = getReport(
941 | "imported-from-entire-module.js",
942 | `
943 | import * as Basis from "basis";
944 |
945 |
946 | `,
947 | {
948 | components: {
949 | "Basis.Header": true,
950 | },
951 | includeSubComponents: true,
952 | importedFrom: "basis",
953 | }
954 | );
955 |
956 | assert.equal(report, {
957 | Basis: {
958 | components: {
959 | Header: {
960 | instances: [
961 | {
962 | importInfo: {
963 | local: "Basis",
964 | moduleName: "basis",
965 | importType: "ImportNamespaceSpecifier",
966 | },
967 | props: {},
968 | propsSpread: false,
969 | location: {
970 | file: "imported-from-entire-module.js",
971 | start: {
972 | line: 4,
973 | column: 5,
974 | },
975 | },
976 | },
977 | ],
978 | },
979 | },
980 | },
981 | });
982 | });
983 |
984 | Scan("custom getComponentName", ({ getReport }) => {
985 | const report = getReport(
986 | "custom-get-component-name.js",
987 | `
988 | import MyBox from "@my/design-system/Box";
989 | import { ImportedText as LocalText } from "@my/design-system/Text";
990 |
991 | <>
992 |
993 |
994 | >
995 | `,
996 | {
997 | getComponentName: ({ moduleName }) => {
998 | const parts = moduleName.split("/");
999 |
1000 | return parts[parts.length - 1];
1001 | },
1002 | }
1003 | );
1004 |
1005 | assert.equal(report, {
1006 | Box: {
1007 | instances: [
1008 | {
1009 | importInfo: {
1010 | local: "MyBox",
1011 | moduleName: "@my/design-system/Box",
1012 | importType: "ImportDefaultSpecifier",
1013 | },
1014 | props: {},
1015 | propsSpread: false,
1016 | location: {
1017 | file: "custom-get-component-name.js",
1018 | start: {
1019 | line: 6,
1020 | column: 7,
1021 | },
1022 | },
1023 | },
1024 | ],
1025 | },
1026 | Text: {
1027 | instances: [
1028 | {
1029 | importInfo: {
1030 | imported: "ImportedText",
1031 | local: "LocalText",
1032 | moduleName: "@my/design-system/Text",
1033 | importType: "ImportSpecifier",
1034 | },
1035 | props: {},
1036 | propsSpread: false,
1037 | location: {
1038 | file: "custom-get-component-name.js",
1039 | start: {
1040 | line: 7,
1041 | column: 7,
1042 | },
1043 | },
1044 | },
1045 | ],
1046 | },
1047 | });
1048 | });
1049 |
1050 | Scan("importAlias", ({ getReport }) => {
1051 | const report = getReport(
1052 | "import-alias.js",
1053 | `
1054 | import Text from "basis";
1055 | import { Text as AliasedText } from "basis";
1056 | import { Text as AnotherAliasedText } from "basis";
1057 | import "./styles.css";
1058 |
1059 | <>
1060 |
1061 |
1062 |
1063 | >
1064 | `
1065 | );
1066 |
1067 | assert.equal(report, {
1068 | Text: {
1069 | instances: [
1070 | {
1071 | importInfo: {
1072 | local: "Text",
1073 | moduleName: "basis",
1074 | importType: "ImportDefaultSpecifier",
1075 | },
1076 | props: {},
1077 | propsSpread: false,
1078 | location: {
1079 | file: "import-alias.js",
1080 | start: {
1081 | line: 8,
1082 | column: 7,
1083 | },
1084 | },
1085 | },
1086 | {
1087 | importInfo: {
1088 | imported: "Text",
1089 | local: "AliasedText",
1090 | moduleName: "basis",
1091 | importType: "ImportSpecifier",
1092 | },
1093 | props: {},
1094 | propsSpread: false,
1095 | location: {
1096 | file: "import-alias.js",
1097 | start: {
1098 | line: 9,
1099 | column: 7,
1100 | },
1101 | },
1102 | },
1103 | {
1104 | importInfo: {
1105 | imported: "Text",
1106 | local: "AnotherAliasedText",
1107 | moduleName: "basis",
1108 | importType: "ImportSpecifier",
1109 | },
1110 | props: {},
1111 | propsSpread: false,
1112 | location: {
1113 | file: "import-alias.js",
1114 | start: {
1115 | line: 10,
1116 | column: 7,
1117 | },
1118 | },
1119 | },
1120 | ],
1121 | },
1122 | });
1123 | });
1124 |
1125 | Scan.run();
1126 |
--------------------------------------------------------------------------------
/src/scanner.js:
--------------------------------------------------------------------------------
1 | const startTime = process.hrtime.bigint();
2 |
3 | const { validateConfig } = require("./utils");
4 | const runScan = require("./run");
5 |
6 | const scanner = {
7 | run: async function run(config, configDir, method = "programmatic") {
8 | const { crawlFrom, errors } = validateConfig(config, configDir);
9 |
10 | if (errors.length === 0) {
11 | return await runScan({
12 | config,
13 | configDir,
14 | crawlFrom,
15 | startTime,
16 | method: method,
17 | });
18 | } else {
19 | console.error(`Config errors:`);
20 |
21 | errors.forEach((error) => {
22 | console.error(`- ${error}`);
23 | });
24 |
25 | process.exit(1);
26 | }
27 | },
28 | };
29 |
30 | module.exports = scanner;
31 |
--------------------------------------------------------------------------------
/src/scanner.test.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const { suite } = require("uvu");
3 | const assert = require("uvu/assert");
4 |
5 | const scanner = require("./scanner");
6 |
7 | const Scanner = suite("Scanner");
8 |
9 | Scanner("no processors", async () => {
10 | const output = await scanner.run({
11 | crawlFrom: "code",
12 | rootDir: path.resolve("./test"),
13 | });
14 |
15 | assert.snapshot(
16 | JSON.stringify(output, undefined, 2),
17 | JSON.stringify(
18 | {
19 | Text: {
20 | instances: 2,
21 | props: {
22 | margin: 1,
23 | textStyle: 1,
24 | },
25 | },
26 | App: {
27 | instances: 1,
28 | props: {},
29 | },
30 | BasisProvider: {
31 | instances: 1,
32 | props: {
33 | theme: 1,
34 | },
35 | },
36 | Home: {
37 | instances: 1,
38 | props: {},
39 | },
40 | Link: {
41 | instances: 1,
42 | props: {
43 | href: 1,
44 | newTab: 1,
45 | },
46 | },
47 | div: {
48 | instances: 1,
49 | props: {
50 | style: 1,
51 | },
52 | },
53 | },
54 | null,
55 | 2
56 | )
57 | );
58 | });
59 |
60 | Scanner("single processor", async () => {
61 | const output = await scanner.run({
62 | crawlFrom: "code",
63 | rootDir: path.resolve("./test"),
64 | processors: ["count-components"],
65 | });
66 |
67 | assert.snapshot(
68 | JSON.stringify(output, undefined, 2),
69 | JSON.stringify(
70 | {
71 | Text: 2,
72 | App: 1,
73 | BasisProvider: 1,
74 | Home: 1,
75 | Link: 1,
76 | div: 1,
77 | },
78 | null,
79 | 2
80 | )
81 | );
82 | });
83 |
84 | Scanner.run();
85 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const { isPlainObject } = require("is-plain-object");
4 | const processors = require("./processors/processors");
5 |
6 | function pluralize(count, word) {
7 | return count === 1 ? `1 ${word}` : `${count} ${word}s`;
8 | }
9 |
10 | function validateConfig(config, configDir) {
11 | const result = {
12 | errors: [],
13 | };
14 |
15 | if (config.crawlFrom === undefined) {
16 | result.errors.push(`crawlFrom is missing`);
17 | } else if (typeof config.crawlFrom !== "string") {
18 | result.errors.push(`crawlFrom should be a string`);
19 | } else {
20 | const crawlFrom = path.resolve(
21 | config.rootDir || configDir,
22 | config.crawlFrom
23 | );
24 |
25 | if (fs.existsSync(crawlFrom)) {
26 | result.crawlFrom = crawlFrom;
27 | } else {
28 | result.errors.push(`crawlFrom path doesn't exist (${crawlFrom})`);
29 | }
30 | }
31 |
32 | if (config.exclude !== undefined) {
33 | if (Array.isArray(config.exclude)) {
34 | for (let i = 0, len = config.exclude.length; i < len; i++) {
35 | if (
36 | typeof config.exclude[i] !== "string" &&
37 | config.exclude[i] instanceof RegExp === false
38 | ) {
39 | result.errors.push(
40 | `every item in the exclude array should be a string or a regex (${typeof config
41 | .exclude[i]} found)`
42 | );
43 | break;
44 | }
45 | }
46 | } else if (typeof config.exclude !== "function") {
47 | result.errors.push(`exclude should be an array or a function`);
48 | }
49 | }
50 |
51 | if (config.globs !== undefined) {
52 | if (Array.isArray(config.globs)) {
53 | for (let i = 0, len = config.globs.length; i < len; i++) {
54 | if (typeof config.globs[i] !== "string") {
55 | result.errors.push(
56 | `every item in the globs array should be a string (${typeof config
57 | .globs[i]} found)`
58 | );
59 | break;
60 | }
61 | }
62 | } else {
63 | result.errors.push(`globs should be an array`);
64 | }
65 | }
66 |
67 | if (config.components !== undefined) {
68 | if (isPlainObject(config.components)) {
69 | for (const componentName in config.components) {
70 | if (config.components[componentName] !== true) {
71 | result.errors.push(
72 | `the only supported value in the components object is true`
73 | );
74 | break;
75 | }
76 | }
77 | } else {
78 | result.errors.push(`components should be an object`);
79 | }
80 | }
81 |
82 | if (config.includeSubComponents !== undefined) {
83 | if (typeof config.includeSubComponents !== "boolean") {
84 | result.errors.push(`includeSubComponents should be a boolean`);
85 | }
86 | }
87 |
88 | if (config.importedFrom !== undefined) {
89 | if (
90 | typeof config.importedFrom !== "string" &&
91 | config.importedFrom instanceof RegExp === false
92 | ) {
93 | result.errors.push(`importedFrom should be a string or a RegExp`);
94 | }
95 | }
96 |
97 | if (config.processors !== undefined) {
98 | if (Array.isArray(config.processors)) {
99 | for (let i = 0, len = config.processors.length; i < len; i++) {
100 | const processor = config.processors[i];
101 |
102 | if (typeof processor === "string") {
103 | if (processors[processor] === undefined) {
104 | result.errors.push(
105 | `unknown processor: ${processor} (known processors are: ${Object.keys(
106 | processors
107 | ).join(", ")})`
108 | );
109 | }
110 | } else if (Array.isArray(processor)) {
111 | if (processor.length !== 2) {
112 | result.errors.push(
113 | `processor is in a form of array should have exactly 2 items (${pluralize(
114 | processor.length,
115 | "item"
116 | )} found)`
117 | );
118 | break;
119 | }
120 |
121 | const [name, options] = processor;
122 |
123 | if (typeof name !== "string") {
124 | result.errors.push(
125 | `when processor is a tuple, the first item is a name and should be a string (${typeof name} found)`
126 | );
127 | break;
128 | } else if (processors[name] === undefined) {
129 | result.errors.push(
130 | `unknown processor: ${name} (known processors are: ${Object.keys(
131 | processors
132 | ).join(", ")})`
133 | );
134 | }
135 |
136 | if (isPlainObject(options) === false) {
137 | result.errors.push(
138 | `when processor is a tuple, the second item is options and should be an object`
139 | );
140 | }
141 | } else if (typeof processor !== "function") {
142 | result.errors.push(
143 | `processor should be a string, an array, or a function (${typeof processor} found)`
144 | );
145 | }
146 | }
147 | } else {
148 | result.errors.push(`processors should be an array`);
149 | }
150 | }
151 |
152 | return result;
153 | }
154 |
155 | const forEachComponent = (report) => (callback) => {
156 | const queue = [{ namePrefix: "", componentsMap: report }];
157 |
158 | while (queue.length > 0) {
159 | const { namePrefix, componentsMap } = queue.shift();
160 |
161 | for (let componentName in componentsMap) {
162 | const component = componentsMap[componentName];
163 | const { components } = component;
164 | const fullComponentName = `${namePrefix}${componentName}`;
165 |
166 | callback({ componentName: fullComponentName, component });
167 |
168 | if (components) {
169 | queue.push({
170 | namePrefix: `${fullComponentName}.`,
171 | componentsMap: components,
172 | });
173 | }
174 | }
175 | }
176 | };
177 |
178 | function sortObjectKeysByValue(obj, mapValue = (value) => value) {
179 | const entries = Object.entries(obj);
180 |
181 | entries.sort(([key1, value1], [key2, value2]) => {
182 | const value1ToCompare = mapValue(value1);
183 | const value2ToCompare = mapValue(value2);
184 |
185 | return value1ToCompare > value2ToCompare ||
186 | (value1ToCompare === value2ToCompare && key1 <= key2)
187 | ? -1
188 | : 1;
189 | });
190 |
191 | return entries.reduce((acc, [key, value]) => {
192 | acc[key] = value;
193 | return acc;
194 | }, {});
195 | }
196 |
197 | function getExcludeFn(configExclude) {
198 | if (Array.isArray(configExclude)) {
199 | return (dir) => {
200 | for (let i = 0, len = configExclude.length; i < len; i++) {
201 | const item = configExclude[i];
202 |
203 | if (
204 | (typeof item === "string" && item === dir) ||
205 | (item instanceof RegExp && item.test(dir))
206 | ) {
207 | return true;
208 | }
209 | }
210 |
211 | return false;
212 | };
213 | }
214 |
215 | if (typeof configExclude === "function") {
216 | return configExclude;
217 | }
218 |
219 | return () => false;
220 | }
221 |
222 | module.exports = {
223 | pluralize,
224 | validateConfig,
225 | forEachComponent,
226 | sortObjectKeysByValue,
227 | getExcludeFn,
228 | };
229 |
--------------------------------------------------------------------------------
/src/utils.test.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const { suite } = require("uvu");
4 | const assert = require("uvu/assert");
5 | const {
6 | validateConfig,
7 | pluralize,
8 | forEachComponent,
9 | sortObjectKeysByValue,
10 | getExcludeFn,
11 | } = require("./utils");
12 |
13 | const ValidateConfig = suite("validateConfig");
14 | const Pluralize = suite("pluralize");
15 | const ForEachComponent = suite("forEachComponent");
16 | const SortObjectKeysByValue = suite("sortObjectKeysByValue");
17 | const GetExcludeFn = suite("getExcludeFn");
18 |
19 | ValidateConfig.before.each((context) => {
20 | context.originalPathResolve = path.resolve;
21 | context.originFsExistsSync = fs.existsSync;
22 |
23 | path.resolve = () => "/Users/misha/oscar/src";
24 | fs.existsSync = () => true;
25 |
26 | context.mock = (fn) => {
27 | fn();
28 | };
29 | });
30 |
31 | ValidateConfig.after.each((context) => {
32 | context.mock = undefined;
33 | path.resolve = context.originalPathResolve;
34 | fs.existsSync = context.originFsExistsSync;
35 | });
36 |
37 | ValidateConfig("crawlFrom is missing", (context) => {
38 | context.mock(() => {
39 | path.resolve = () => "";
40 | fs.existsSync = () => false;
41 | });
42 |
43 | const result = validateConfig({}, "/Users/misha/oscar");
44 |
45 | assert.equal(result, {
46 | errors: [`crawlFrom is missing`],
47 | });
48 | });
49 |
50 | ValidateConfig("crawlFrom should be a string", (context) => {
51 | context.mock(() => {
52 | path.resolve = () => "";
53 | fs.existsSync = () => false;
54 | });
55 |
56 | const result = validateConfig(
57 | {
58 | crawlFrom: true,
59 | },
60 | "/Users/misha/oscar"
61 | );
62 |
63 | assert.equal(result, {
64 | errors: [`crawlFrom should be a string`],
65 | });
66 | });
67 |
68 | ValidateConfig("crawlFrom path doesn't exist", (context) => {
69 | context.mock(() => {
70 | fs.existsSync = () => false;
71 | });
72 |
73 | const result = validateConfig(
74 | {
75 | crawlFrom: "./src",
76 | },
77 | "/Users/misha/oscar"
78 | );
79 |
80 | assert.equal(result, {
81 | errors: [`crawlFrom path doesn't exist (/Users/misha/oscar/src)`],
82 | });
83 | });
84 |
85 | ValidateConfig("exclude is an array with invalid items", () => {
86 | const result = validateConfig(
87 | {
88 | crawlFrom: "./src",
89 | exclude: ["utils", /node_modules/, undefined],
90 | },
91 | "/Users/misha/oscar"
92 | );
93 |
94 | assert.equal(result, {
95 | crawlFrom: "/Users/misha/oscar/src",
96 | errors: [
97 | `every item in the exclude array should be a string or a regex (undefined found)`,
98 | ],
99 | });
100 | });
101 |
102 | ValidateConfig("exclude is neither an array nor a function", () => {
103 | const result = validateConfig(
104 | {
105 | crawlFrom: "./src",
106 | exclude: "utils",
107 | },
108 | "/Users/misha/oscar"
109 | );
110 |
111 | assert.equal(result, {
112 | crawlFrom: "/Users/misha/oscar/src",
113 | errors: [`exclude should be an array or a function`],
114 | });
115 | });
116 |
117 | ValidateConfig("globs is not an array", () => {
118 | const result = validateConfig(
119 | {
120 | crawlFrom: "./src",
121 | globs: "**/*.js",
122 | },
123 | "/Users/misha/oscar"
124 | );
125 |
126 | assert.equal(result, {
127 | crawlFrom: "/Users/misha/oscar/src",
128 | errors: [`globs should be an array`],
129 | });
130 | });
131 |
132 | ValidateConfig("globs has a non string item", () => {
133 | const result = validateConfig(
134 | {
135 | crawlFrom: "./src",
136 | globs: ["**/*.js", 4],
137 | },
138 | "/Users/misha/oscar"
139 | );
140 |
141 | assert.equal(result, {
142 | crawlFrom: "/Users/misha/oscar/src",
143 | errors: [`every item in the globs array should be a string (number found)`],
144 | });
145 | });
146 |
147 | ValidateConfig("components is not an object", () => {
148 | const result = validateConfig(
149 | {
150 | crawlFrom: "./src",
151 | components: "Header",
152 | },
153 | "/Users/misha/oscar"
154 | );
155 |
156 | assert.equal(result, {
157 | crawlFrom: "/Users/misha/oscar/src",
158 | errors: [`components should be an object`],
159 | });
160 | });
161 |
162 | ValidateConfig("components has a non true value", () => {
163 | const result = validateConfig(
164 | {
165 | crawlFrom: "./src",
166 | components: {
167 | Header: false,
168 | },
169 | },
170 | "/Users/misha/oscar"
171 | );
172 |
173 | assert.equal(result, {
174 | crawlFrom: "/Users/misha/oscar/src",
175 | errors: [`the only supported value in the components object is true`],
176 | });
177 | });
178 |
179 | ValidateConfig("includeSubComponents is not a boolean", () => {
180 | const result = validateConfig(
181 | {
182 | crawlFrom: "./src",
183 | includeSubComponents: "yes",
184 | },
185 | "/Users/misha/oscar"
186 | );
187 |
188 | assert.equal(result, {
189 | crawlFrom: "/Users/misha/oscar/src",
190 | errors: [`includeSubComponents should be a boolean`],
191 | });
192 | });
193 |
194 | ValidateConfig("importedFrom is not a string or a RegExp", () => {
195 | const result = validateConfig(
196 | {
197 | crawlFrom: "./src",
198 | importedFrom: ["basis"],
199 | },
200 | "/Users/misha/oscar"
201 | );
202 |
203 | assert.equal(result, {
204 | crawlFrom: "/Users/misha/oscar/src",
205 | errors: [`importedFrom should be a string or a RegExp`],
206 | });
207 | });
208 |
209 | ValidateConfig("processors is not an array", () => {
210 | const result = validateConfig(
211 | {
212 | crawlFrom: "./src",
213 | processors: "count-components",
214 | },
215 | "/Users/misha/oscar"
216 | );
217 |
218 | assert.equal(result, {
219 | crawlFrom: "/Users/misha/oscar/src",
220 | errors: [`processors should be an array`],
221 | });
222 | });
223 |
224 | ValidateConfig("string form - unknown processor", () => {
225 | const result = validateConfig(
226 | {
227 | crawlFrom: "./src",
228 | processors: ["foo"],
229 | },
230 | "/Users/misha/oscar"
231 | );
232 |
233 | assert.is(result.crawlFrom, "/Users/misha/oscar/src");
234 | assert.is(result.errors.length, 1);
235 | assert.ok(/^unknown processor: foo/.test(result.errors[0]));
236 | });
237 |
238 | ValidateConfig("array form - not a tuple", () => {
239 | const result = validateConfig(
240 | {
241 | crawlFrom: "./src",
242 | processors: [["count-components"]],
243 | },
244 | "/Users/misha/oscar"
245 | );
246 |
247 | assert.equal(result, {
248 | crawlFrom: "/Users/misha/oscar/src",
249 | errors: [
250 | `processor is in a form of array should have exactly 2 items (1 item found)`,
251 | ],
252 | });
253 | });
254 |
255 | ValidateConfig("array form - processor name is not a string", () => {
256 | const result = validateConfig(
257 | {
258 | crawlFrom: "./src",
259 | processors: [[() => {}, "count-components"]],
260 | },
261 | "/Users/misha/oscar"
262 | );
263 |
264 | assert.equal(result, {
265 | crawlFrom: "/Users/misha/oscar/src",
266 | errors: [
267 | `when processor is a tuple, the first item is a name and should be a string (function found)`,
268 | ],
269 | });
270 | });
271 |
272 | ValidateConfig("array form - unknown processor", () => {
273 | const result = validateConfig(
274 | {
275 | crawlFrom: "./src",
276 | processors: [["foo", {}]],
277 | },
278 | "/Users/misha/oscar"
279 | );
280 |
281 | assert.is(result.crawlFrom, "/Users/misha/oscar/src");
282 | assert.is(result.errors.length, 1);
283 | assert.ok(/^unknown processor: foo/.test(result.errors[0]));
284 | });
285 |
286 | ValidateConfig("array form - processor options is not an object", () => {
287 | const result = validateConfig(
288 | {
289 | crawlFrom: "./src",
290 | processors: [["count-components", () => {}]],
291 | },
292 | "/Users/misha/oscar"
293 | );
294 |
295 | assert.equal(result, {
296 | crawlFrom: "/Users/misha/oscar/src",
297 | errors: [
298 | `when processor is a tuple, the second item is options and should be an object`,
299 | ],
300 | });
301 | });
302 |
303 | ValidateConfig("array form - processor name is unsupported type", () => {
304 | const result = validateConfig(
305 | {
306 | crawlFrom: "./src",
307 | processors: [true, () => {}],
308 | },
309 | "/Users/misha/oscar"
310 | );
311 |
312 | assert.equal(result, {
313 | crawlFrom: "/Users/misha/oscar/src",
314 | errors: [
315 | `processor should be a string, an array, or a function (boolean found)`,
316 | ],
317 | });
318 | });
319 |
320 | ValidateConfig("valid config with all options", () => {
321 | const result = validateConfig(
322 | {
323 | crawlFrom: "./src",
324 | exclude: ["utils"],
325 | globs: ["**/*.js"],
326 | components: {
327 | Button: true,
328 | Footer: true,
329 | Text: true,
330 | },
331 | includeSubComponents: true,
332 | importedFrom: "basis",
333 | processors: ["count-components"],
334 | },
335 | "/Users/misha/oscar"
336 | );
337 |
338 | assert.equal(result, {
339 | crawlFrom: "/Users/misha/oscar/src",
340 | errors: [],
341 | });
342 | });
343 |
344 | Pluralize("count = 1", () => {
345 | assert.is(pluralize(1, "car"), "1 car");
346 | });
347 |
348 | Pluralize("count > 1", () => {
349 | assert.is(pluralize(3, "car"), "3 cars");
350 | });
351 |
352 | ForEachComponent("visits every component", () => {
353 | const report = {
354 | Header: {
355 | id: 1,
356 | components: {
357 | Logo: {
358 | id: 4,
359 | },
360 | },
361 | },
362 | Footer: {
363 | id: 2,
364 | components: {
365 | Links: {
366 | id: 5,
367 | components: {
368 | Section: {
369 | id: 6,
370 | },
371 | },
372 | },
373 | },
374 | },
375 | Text: {
376 | id: 3,
377 | },
378 | };
379 |
380 | const visits = [];
381 |
382 | forEachComponent(report)(({ componentName, component }) => {
383 | visits.push({
384 | id: component.id,
385 | name: componentName,
386 | });
387 | });
388 |
389 | assert.equal(visits, [
390 | {
391 | id: 1,
392 | name: "Header",
393 | },
394 | {
395 | id: 2,
396 | name: "Footer",
397 | },
398 | {
399 | id: 3,
400 | name: "Text",
401 | },
402 | {
403 | id: 4,
404 | name: "Header.Logo",
405 | },
406 | {
407 | id: 5,
408 | name: "Footer.Links",
409 | },
410 | {
411 | id: 6,
412 | name: "Footer.Links.Section",
413 | },
414 | ]);
415 | });
416 |
417 | SortObjectKeysByValue("default valueMap", () => {
418 | const result = sortObjectKeysByValue({
419 | Header: 5,
420 | Link: 10,
421 | Accordion: 7,
422 | Footer: 16,
423 | });
424 |
425 | assert.equal(result, {
426 | Footer: 16,
427 | Link: 10,
428 | Accordion: 7,
429 | Header: 5,
430 | });
431 | });
432 |
433 | SortObjectKeysByValue("custom valueMap", () => {
434 | const result = sortObjectKeysByValue(
435 | {
436 | Header: {
437 | instances: 16,
438 | },
439 | Link: {
440 | instances: 10,
441 | },
442 | Accordion: {
443 | instances: 7,
444 | },
445 | Footer: {
446 | instances: 16,
447 | },
448 | },
449 | (component) => component.instances
450 | );
451 |
452 | assert.equal(result, {
453 | Footer: {
454 | instances: 16,
455 | },
456 | Link: {
457 | instances: 10,
458 | },
459 | Accordion: {
460 | instances: 7,
461 | },
462 | Header: {
463 | instances: 16,
464 | },
465 | });
466 | });
467 |
468 | GetExcludeFn("array of strings", () => {
469 | const excludeFn = getExcludeFn(["node_modules", "utils"]);
470 |
471 | assert.is(excludeFn("node_modules"), true);
472 | assert.is(excludeFn("utils"), true);
473 | assert.is(excludeFn("foo"), false);
474 | });
475 |
476 | GetExcludeFn("array of strings and regexes", () => {
477 | const excludeFn = getExcludeFn([/image/i, "utils", /test/]);
478 |
479 | assert.is(excludeFn("Images"), true);
480 | assert.is(excludeFn("utils"), true);
481 | assert.is(excludeFn("__test__"), true);
482 | assert.is(excludeFn("foo"), false);
483 | });
484 |
485 | GetExcludeFn("custom function", () => {
486 | const excludeFn = getExcludeFn((dir) => {
487 | return dir === "foo" || dir.endsWith("bar") || dir.length === 7;
488 | });
489 |
490 | assert.is(excludeFn("foo"), true);
491 | assert.is(excludeFn("info-bar"), true);
492 | assert.is(excludeFn("1234567"), true);
493 | assert.is(excludeFn("something-else"), false);
494 | });
495 |
496 | ValidateConfig.run();
497 | Pluralize.run();
498 | ForEachComponent.run();
499 | SortObjectKeysByValue.run();
500 | GetExcludeFn.run();
501 |
--------------------------------------------------------------------------------
/test/code/Home.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Text, Link } from "basis";
3 |
4 | function Home() {
5 | return (
6 |
7 |
8 | Want to know how your design system components are being used?
9 |
10 |
11 | Try{" "}
12 |
13 | react-scanner
14 |
15 |
16 | Hope you like it :)
17 |
18 | );
19 | }
20 |
21 | export default Home;
22 |
--------------------------------------------------------------------------------
/test/code/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import { BasisProvider, defaultTheme } from "basis";
4 | import Home from "./Home";
5 |
6 | function App() {
7 | return (
8 |
9 |
10 |
11 | );
12 | }
13 |
14 | ReactDOM.render(, document.getElementById("root"));
15 |
--------------------------------------------------------------------------------
/test/configs/invalid.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | crawlFrom: ["../code"],
3 | exclude: "tests",
4 | };
5 |
--------------------------------------------------------------------------------
/test/configs/multipleProcessors.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | crawlFrom: "../code",
3 | includeSubComponents: true,
4 | importedFrom: /react|basis/,
5 | processors: [
6 | "count-components",
7 | [
8 | "count-components-and-props",
9 | {
10 | outputTo: "../reports/multipleProcessors-countComponentsAndProps.json",
11 | },
12 | ],
13 | ({ output }) => {
14 | output("something", "../reports/multipleProcessors-custom.txt");
15 | },
16 | ],
17 | };
18 |
--------------------------------------------------------------------------------
/test/configs/noFilesFound.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | crawlFrom: "../code",
3 | globs: ["**/*.jsx"],
4 | };
5 |
--------------------------------------------------------------------------------
/test/configs/noProcessors.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | crawlFrom: "../code",
3 | };
4 |
--------------------------------------------------------------------------------
/test/configs/singleProcessor.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | crawlFrom: "../code",
3 | processors: [
4 | ["count-components", { outputTo: "../reports/singleProcessor.json" }],
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------