Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,101 @@ Here are all the available config options:
| `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.<br>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.<br>Default: `({ imported, local, moduleName, importType }) => imported \|\| local` |
| `getPropValue` | function | Customize reporting for non-trivial prop values. See [Customizing prop values treatment](#customizing-prop-values-treatment) |
| `resolveImport` | function | Optionally record imports without JSX. See [External resolution hooks](#external-resolution-hooks). |
| `resolveComponent` | function | Optionally resolve JSX tags using an external symbol resolver. See [External resolution hooks](#external-resolution-hooks). |
| `processors` | array | See [Processors](#processors).<br>Default: `["count-components-and-props"]` |

## External resolution hooks

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.

### resolveImport

Called for every default, named or namespace import specifier, including unused
and type-only imports:

```js
resolveImport({ filePath, node, specifier, importInfo });
```

- `filePath`: the source file path.
- `node`: the ESTree `ImportDeclaration`.
- `specifier`: the ESTree import specifier, including its `loc.start`.
- `importInfo`: the existing import metadata (`imported` when present, `local`,
`moduleName`, and `importType`).

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.

```js
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.

### resolveComponent

Called for each JSX opening tag:

```js
resolveComponent({ filePath, node, name });
```

- `filePath`: the source file path.
- `node`: the ESTree `JSXOpeningElement`; `node.name.loc.start` identifies the tag.
- `name`: the source tag name, such as `Alias` or `Menu.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:

```js
{
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.

## Processors

Scanning the files results in a JSON report. Add processors to tell `react-scanner` what to do with this report.
Expand Down
221 changes: 221 additions & 0 deletions src/resolve-hooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
const { suite } = require("uvu");
const assert = require("uvu/assert");
const path = require("path");
const scan = require("./scan");
const scanner = require("./scanner");
const { validateConfig } = require("./utils");

const Hooks = suite("resolve hooks");
const filePath = "example.tsx";
const importInfo = {
imported: "Button",
local: "Alias",
moduleName: "example-ui",
importType: "ImportSpecifier",
};
const getReport = (code, config = {}) => {
const report = {};
scan({ code, filePath, report, ...config });
return report;
};

Hooks("records unused imports with their specifier location", () => {
const report = getReport(
'import { Button as Alias, Label } from "example-ui";',
{
resolveImport: ({
filePath: file,
node,
specifier,
importInfo: info,
}) => {
assert.is(node.type, "ImportDeclaration");
return {
componentName: info.imported,
importRecord: { ...info, file, start: specifier.loc.start },
};
},
}
);
assert.equal(report.Button, {
instances: [],
imports: [{ ...importInfo, file: filePath, start: { line: 1, column: 9 } }],
});
assert.is(report.Label.imports.length, 1);
});

Hooks("can decline imports and append repeated import records", () => {
const report = getReport(
'import { Button } from "example-ui";\nimport { Button as Other } from "example-ui";\nimport { Ignore } from "other-ui";',
{
resolveImport: ({ importInfo: info }) => {
if (info.moduleName !== "example-ui") return null;
return { componentName: "Button", importRecord: info.local };
},
}
);
assert.equal(report.Button, { instances: [], imports: ["Button", "Other"] });
assert.is(report.Ignore, undefined);
});

Hooks("keeps import records separate from rendered instances", () => {
const report = getReport('import { Button } from "example-ui"; <Button />;', {
resolveImport: () => ({ componentName: "Button", importRecord: "import" }),
});
assert.equal(report.Button.imports, ["import"]);
assert.is(report.Button.instances.length, 1);
});

Hooks("stores nested imports with no rendered root", () => {
const report = getReport('import { Item } from "example-ui";', {
resolveImport: () => ({ componentName: "Menu.Item", importRecord: "item" }),
});
assert.equal(report.Menu.components.Item, {
instances: [],
imports: ["item"],
});
});

Hooks("exposes default, namespace and type-only import metadata", () => {
const report = getReport(
'import Default from "example-ui"; import * as Menu from "example-ui"; import type { Button } from "example-ui"; import { type Label } from "example-ui";',
{
resolveImport: ({ node, specifier, importInfo: info }) => ({
componentName: info.imported || info.local,
importRecord: {
importType: info.importType,
typeOnly:
node.importKind === "type" || specifier.importKind === "type",
},
}),
}
);
assert.equal(report.Default.imports, [
{ importType: "ImportDefaultSpecifier", typeOnly: false },
]);
assert.equal(report.Menu.imports, [
{ importType: "ImportNamespaceSpecifier", typeOnly: false },
]);
assert.equal(report.Button.imports, [
{ importType: "ImportSpecifier", typeOnly: true },
]);
assert.equal(report.Label.imports, [
{ importType: "ImportSpecifier", typeOnly: true },
]);
});

Hooks("resolves a tag imported through a different module", () => {
const report = getReport(
'import { Alias } from "./barrel"; <Alias tone="quiet" />;',
{
importedFrom: "example-ui",
getComponentName: () => "Ignored",
resolveComponent: ({ filePath: file, node, name }) => {
assert.is(file, filePath);
assert.is(node.type, "JSXOpeningElement");
assert.is(name, "Alias");
return { componentName: "Button", importInfo };
},
}
);
assert.equal(report.Button.instances[0].importInfo, importInfo);
assert.equal(report.Button.instances[0].props, { tone: "quiet" });
assert.is(report.Alias, undefined);
assert.is(report.Ignored, undefined);
});

Hooks("declines a shadowed tag without falling back to the import name", () => {
const report = getReport(
'import { Button } from "example-ui"; function Example(Button) { return <Button />; }',
{ resolveComponent: () => null }
);
assert.equal(report, {});
});

Hooks("uses resolved names for component and subcomponent filters", () => {
const resolveComponent = () => ({ componentName: "Menu.Item", importInfo });
assert.equal(getReport("<Alias />", { resolveComponent }), {});
assert.equal(
getReport("<Alias />", {
resolveComponent,
includeSubComponents: true,
components: { Button: true },
}),
{}
);
const report = getReport("<Alias />", {
resolveComponent,
includeSubComponents: true,
components: { "Menu.Item": true },
});
assert.is(report.Menu.components.Item.instances.length, 1);
});

Hooks("uses resolved import metadata for module filters", () => {
const resolveComponent = () => ({ componentName: "Button", importInfo });
assert.equal(
getReport("<Alias />", { resolveComponent, importedFrom: "other-ui" }),
{}
);
assert.equal(
getReport("<Alias />", { resolveComponent, importedFrom: /other-ui/ }),
{}
);
assert.is(
getReport("<Alias />", { resolveComponent, importedFrom: /example-ui/ })
.Button.instances.length,
1
);
assert.equal(
getReport("<Alias />", {
resolveComponent: () => ({ componentName: "Button" }),
importedFrom: "example-ui",
}),
{}
);
assert.is(
getReport("<Alias />", {
resolveComponent: () => ({ componentName: "Button" }),
}).Button.instances.length,
1
);
});

Hooks("forwards resolver options through scanner.run", async () => {
let imports = 0;
let components = 0;
const output = await scanner.run({
rootDir: path.resolve("test"),
crawlFrom: "code",
resolveImport: () => {
imports += 1;
return { componentName: "Example", importRecord: "import" };
},
resolveComponent: () => {
components += 1;
return { componentName: "Example" };
},
processors: [({ report }) => report],
});
assert.ok(imports > 0);
assert.ok(components > 0);
assert.is(output.Example.imports.length, imports);
assert.is(output.Example.instances.length, components);
});

Hooks("validates both optional callback types", () => {
const config = { crawlFrom: "test" };
assert.equal(validateConfig(config, process.cwd()).errors, []);
for (const hook of ["resolveImport", "resolveComponent"]) {
assert.equal(
validateConfig({ ...config, [hook]: () => {} }, process.cwd()).errors,
[]
);
assert.equal(
validateConfig({ ...config, [hook]: "invalid" }, process.cwd()).errors,
[`${hook} should be a function`]
);
}
});

Hooks.run();
4 changes: 4 additions & 0 deletions src/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ async function run({
importedFrom,
getComponentName,
getPropValue,
resolveImport,
resolveComponent,
} = config;

for (let i = 0, len = files.length; i < len; i++) {
Expand All @@ -56,6 +58,8 @@ async function run({
getComponentName,
report,
getPropValue,
resolveImport,
resolveComponent,
});
}

Expand Down
Loading