This repository is a fork of react-scanner by Misha Moroshko. The original MIT license and copyright are preserved in LICENSE.
This fork adds the optional resolveImport and resolveComponent hooks described
below, with generic tests and callback validation. They let callers supply import
evidence and use an external symbol resolver while preserving default behavior.
The focused upstream contribution is draft PR #85.
Dependency versions remain unchanged. Updating TypeScript and the parser is kept separate from the hook contribution because it affects the order of an existing test's output.
react-scanner statically analyzes the given code (TypeScript supported) and extracts React components and props usage.
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.
For example, let's say we have the following index.js file:
import React from "react";
import ReactDOM from "react-dom";
import {
BasisProvider,
defaultTheme,
Container,
Text,
Link as BasisLink,
} from "basis";
function App() {
return (
<BasisProvider theme={defaultTheme}>
<Container margin="4" hasBreakpointWidth>
<Text textStyle="subtitle2">
Want to know how your design system components are being used?
</Text>
<Text margin="4 0 0 0">
Try{" "}
<BasisLink href="https://github.com/moroshko/react-scanner" newTab>
react-scanner
</BasisLink>
</Text>
</Container>
</BasisProvider>
);
}
ReactDOM.render(<App />, document.getElementById("root"));Running react-scanner on it will create the following JSON report:
Click to see it
{
"BasisProvider": {
"instances": [
{
"importInfo": {
"imported": "BasisProvider",
"local": "BasisProvider",
"moduleName": "basis"
},
"props": {
"theme": "(Identifier)"
},
"propsSpread": false,
"location": {
"file": "/path/to/index.js",
"start": {
"line": 13,
"column": 5
}
}
}
]
},
"Container": {
"instances": [
{
"importInfo": {
"imported": "Container",
"local": "Container",
"moduleName": "basis"
},
"props": {
"margin": "4",
"hasBreakpointWidth": null
},
"propsSpread": false,
"location": {
"file": "/path/to/index.js",
"start": {
"line": 14,
"column": 7
}
}
}
]
},
"Text": {
"instances": [
{
"importInfo": {
"imported": "Text",
"local": "Text",
"moduleName": "basis"
},
"props": {
"textStyle": "subtitle2"
},
"propsSpread": false,
"location": {
"file": "/path/to/index.js",
"start": {
"line": 15,
"column": 9
}
}
},
{
"importInfo": {
"imported": "Text",
"local": "Text",
"moduleName": "basis"
},
"props": {
"margin": "4 0 0 0"
},
"propsSpread": false,
"location": {
"file": "/path/to/index.js",
"start": {
"line": 18,
"column": 9
}
}
}
]
},
"Link": {
"instances": [
{
"importInfo": {
"imported": "Link",
"local": "BasisLink",
"moduleName": "basis"
},
"props": {
"href": "https://github.com/moroshko/react-scanner",
"newTab": null
},
"propsSpread": false,
"location": {
"file": "/path/to/index.js",
"start": {
"line": 20,
"column": 11
}
}
}
]
}
}This raw JSON report is used then to generate something that is useful to you. For example, you might want to know:
- How often a cetrain component is used in your design system? (see
count-componentsprocessor) - How often a certain prop in a given component is used? (see
count-components-and-propsprocessor) - Looking at some prop in a given component, what's the distribution of values used? (e.g. you might consider deprecating a certain value)
Once you have the result you are interested in, you can write it to a file or simply log it to the console.
npm install --save-dev react-scanner
npx react-scanner -c /path/to/react-scanner.config.js
Everything that react-scanner does is controlled by a config file.
The config file can be located anywhere and it must export an object like this:
module.exports = {
crawlFrom: "./src",
includeSubComponents: true,
importedFrom: "basis",
};Running react-scanner with this config would output something like this to the console:
{
"Text": {
"instances": 17,
"props": {
"margin": 6,
"color": 4,
"textStyle": 1
}
},
"Button": {
"instances": 10,
"props": {
"width": 10,
"variant": 5,
"type": 3
}
},
"Footer": {
"instances": 1,
"props": {}
}
}It is also possible to run the scanner programmatically. In this case, the config options should be passed directly to the run function.
import scanner from "react-scanner";
const output = await scanner.run(config);Here are all the available config options:
| Option | Type | Description |
|---|---|---|
rootDir |
string | The path to the root directory of your project. If using a config file, this defaults to the config directory. |
crawlFrom |
string | The path of the directory to start crawling from. Absolute or relative to the config file location. |
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. |
globs |
array | Only files matching these globs will be scanned. See here for glob syntax. Default: ["**/!(*.test|*.spec).@(js|ts)?(x)"] |
components |
object | Components to report. Omit to report all components. |
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 |
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. |
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 |
getPropValue |
function | Customize reporting for non-trivial prop values. See Customizing prop values treatment |
resolveImport |
function | Optionally record imports without JSX. See External resolution hooks. |
resolveComponent |
function | Optionally resolve JSX tags using an external symbol resolver. See External resolution hooks. |
processors |
array | See Processors. Default: ["count-components-and-props"] |
The optional synchronous resolveImport and resolveComponent callbacks let an
external resolver supply import records and canonical component identities. For
example, a caller can reuse a TypeScript program to identify a component through
reexports or distinguish an imported name from a shadowed local variable. The
scanner does not create a TypeScript program or resolve modules itself.
Without these callbacks, the existing import and JSX reporting stays unchanged. The callbacks are independent: either can be configured on its own.
Called for every default, named or namespace import specifier, including unused and type-only imports:
resolveImport({ filePath, node, specifier, importInfo });filePath: the source file path.node: the ESTreeImportDeclaration.specifier: the ESTree import specifier, including itsloc.start.importInfo: the existing import metadata (importedwhen present,local,moduleName, andimportType).
Return { componentName, importRecord } to append a record to that component's
imports array in the raw report. importRecord is caller-defined data. A falsy
return value skips the record. Dotted component names use the same nested
components structure as JSX subcomponents.
resolveImport: ({ filePath, node, specifier, importInfo }) => {
if (importInfo.moduleName !== "example-ui") {
return null;
}
return {
componentName: importInfo.imported || importInfo.local,
importRecord: {
...importInfo,
typeOnly: node.importKind === "type" || specifier.importKind === "type",
location: { file: filePath, start: specifier.loc.start },
},
};
},An import record does not create a rendered instance. An import-only component
has instances: []; built-in count processors report zero instances and count no
props for it. Import-record selection is controlled by this callback, independently
of the JSX components, importedFrom and includeSubComponents filters. Source
lines start at one and columns start at zero, consistent with JSX locations.
Called for each JSX opening tag:
resolveComponent({ filePath, node, name });filePath: the source file path.node: the ESTreeJSXOpeningElement;node.name.loc.startidentifies the tag.name: the source tag name, such asAliasorMenu.Item.
Return { componentName, importInfo } with the canonical component name and
optional import metadata. For example, an external resolver can map an Alias
tag imported through a local barrel to:
{
componentName: "Button",
importInfo: {
imported: "Button",
local: "Alias",
moduleName: "example-ui",
importType: "ImportSpecifier",
},
}A falsy result excludes that JSX tag; there is no fallback to the scanner's
file-local import lookup when the callback is configured. This lets the resolver
exclude shadowed identifiers. Resolved names bypass getComponentName and are
still subject to components and includeSubComponents; importedFrom uses the
returned importInfo.moduleName. Supply import metadata when using that filter.
The scanner continues to extract props, spread flags and JSX locations. Resolution does not evaluate runtime expressions or propagate props through wrappers.
Scanning the files results in a JSON report. Add processors to tell react-scanner what to do with this report.
react-scanner comes with some ready to use processors.
To use a built-in processor, simply specify its name as a string, e.g.:
processors: ["count-components"]
You can also use a tuple form to pass options to a built-in processor, e.g.:
processors: [
["count-components", { outputTo: "/path/to/my-report.json" }]
]
All the built-in processors support the following options:
| Option | Type | Description |
|---|---|---|
outputTo |
string | Where to output the result. Absolute or relative to the root directory. When omitted, the result is printed out to the console. |
Here are the built-in processors that react-scanner comes with:
Example output:
{
"Text": 10,
"Button": 5,
"Link": 3
}Example output:
{
"Text": {
"instances": 17,
"props": {
"margin": 6,
"color": 4,
"textStyle": 1
}
},
"Button": {
"instances": 10,
"props": {
"width": 10,
"variant": 4,
"type": 2
}
},
"Footer": {
"instances": 1,
"props": {}
}
}Example output:
{
"Text": {
"instances": [
{
"props": {
"textStyle": "subtitle2"
},
"propsSpread": false,
"location": {
"file": "/path/to/file",
"start": {
"line": 9,
"column": 9
}
}
},
{
"props": {
"margin": "4 0 0 0"
},
"propsSpread": false,
"location": {
"file": "/path/to/file",
"start": {
"line": 12,
"column": 9
}
}
}
]
},
"Link": {
"instances": [
{
"props": {
"href": "https://github.com/moroshko/react-scanner",
"newTab": null
},
"propsSpread": false,
"location": {
"file": "/path/to/file",
"start": {
"line": 14,
"column": 11
}
}
}
]
},
"Container": {
"instances": [
{
"props": {
"margin": "4",
"hasBreakpointWidth": null
},
"propsSpread": false,
"location": {
"file": "/path/to/file",
"start": {
"line": 8,
"column": 7
}
}
}
]
}
}We saw above that built-in processors come in the form of a string or a tuple.
Custom processors are functions, and can be asynchronous!
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.
Here is an example of taking the output of the built-in count-components-and-props processor and sending it to your storage solution.
processors: [
"count-components-and-props",
({ prevResult }) => {
return axios.post("/my/storage/solution", prevResult);
}
]
Processor functions receive an object with the following keys in it:
| Key | Type | Description |
|---|---|---|
report |
object | The raw JSON report. |
prevResults |
array | Previous processors results. |
prevResult |
any | The last item in prevResults. Just for convenience. |
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. |
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. |
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. |
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.
Using the getPropValue configuration parameter makes this possible.
type IGetPropValue = {
/** The AST node */
node: Node,
componentName: string,
propName: string,
/** Pass the node back into this method for default handling of the prop value */
defaultGetPropValue: (node: Node) => string
}
getPropValue({ node, componentName, propName, defaultGetPropValue }: IGetPropValue): stringIf 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:
const escodegen = require("escodegen-wallaby");
getPropValue: ({ node, propName, componentName, defaultGetPropValue }) => {
if (componentName === "Input" && propName === "style") {
if (node.type === "JSXExpressionContainer") {
return escodegen.generate(node.expression);
} else {
return escodegen.generate(node);
}
} else {
return defaultGetPropValue(node);
}
};MIT