diff --git a/README.md b/README.md
index 0a64b11..5519cf1 100644
--- a/README.md
+++ b/README.md
@@ -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.
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](#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).
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.
diff --git a/src/resolve-hooks.test.js b/src/resolve-hooks.test.js
new file mode 100644
index 0000000..799f2ed
--- /dev/null
+++ b/src/resolve-hooks.test.js
@@ -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"; ;', {
+ 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"; ;',
+ {
+ 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 ; }',
+ { resolveComponent: () => null }
+ );
+ assert.equal(report, {});
+});
+
+Hooks("uses resolved names for component and subcomponent filters", () => {
+ const resolveComponent = () => ({ componentName: "Menu.Item", importInfo });
+ assert.equal(getReport("", { resolveComponent }), {});
+ assert.equal(
+ getReport("", {
+ resolveComponent,
+ includeSubComponents: true,
+ components: { Button: true },
+ }),
+ {}
+ );
+ const report = getReport("", {
+ 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("", { resolveComponent, importedFrom: "other-ui" }),
+ {}
+ );
+ assert.equal(
+ getReport("", { resolveComponent, importedFrom: /other-ui/ }),
+ {}
+ );
+ assert.is(
+ getReport("", { resolveComponent, importedFrom: /example-ui/ })
+ .Button.instances.length,
+ 1
+ );
+ assert.equal(
+ getReport("", {
+ resolveComponent: () => ({ componentName: "Button" }),
+ importedFrom: "example-ui",
+ }),
+ {}
+ );
+ assert.is(
+ getReport("", {
+ 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();
diff --git a/src/run.js b/src/run.js
index 7f2a0ed..2895d5f 100644
--- a/src/run.js
+++ b/src/run.js
@@ -41,6 +41,8 @@ async function run({
importedFrom,
getComponentName,
getPropValue,
+ resolveImport,
+ resolveComponent,
} = config;
for (let i = 0, len = files.length; i < len; i++) {
@@ -56,6 +58,8 @@ async function run({
getComponentName,
report,
getPropValue,
+ resolveImport,
+ resolveComponent,
});
}
diff --git a/src/scan.js b/src/scan.js
index b932e25..8a586a5 100644
--- a/src/scan.js
+++ b/src/scan.js
@@ -100,6 +100,8 @@ function scan({
imported === "default" ? local : imported || local,
report,
getPropValue,
+ resolveImport,
+ resolveComponent,
}) {
let ast;
@@ -134,6 +136,28 @@ function scan({
moduleName,
importType: specifiers[i].type,
};
+ if (resolveImport) {
+ const resolved = resolveImport({
+ filePath,
+ node,
+ specifier: specifiers[i],
+ importInfo: importsMap[local],
+ });
+ if (resolved) {
+ const componentPath = resolved.componentName
+ .split(".")
+ .join(".components.");
+ let component = getObjectPath(report, componentPath);
+ if (!component) {
+ component = { instances: [] };
+ dset(report, componentPath, component);
+ }
+ if (!component.imports) {
+ component.imports = [];
+ }
+ component.imports.push(resolved.importRecord);
+ }
+ }
break;
}
@@ -149,11 +173,18 @@ function scan({
JSXOpeningElement: {
exit(node) {
const name = getComponentNameFromAST(node.name);
- const nameParts = name.split(".");
+ const resolved =
+ resolveComponent && resolveComponent({ filePath, node, name });
+ if (resolveComponent && !resolved) return astray.SKIP;
+ const nameParts = (resolved ? resolved.componentName : name).split(".");
const [firstPart, ...restParts] = nameParts;
- const actualFirstPart = importsMap[firstPart]
- ? getComponentName(importsMap[firstPart])
- : firstPart;
+ const effectiveImport = resolved
+ ? resolved.importInfo
+ : importsMap[firstPart];
+ const actualFirstPart =
+ !resolved && importsMap[firstPart]
+ ? getComponentName(importsMap[firstPart])
+ : firstPart;
const shouldReportComponent = () => {
if (components) {
if (nameParts.length === 1) {
@@ -181,11 +212,11 @@ function scan({
}
if (importedFrom) {
- if (!importsMap[firstPart]) {
+ if (!effectiveImport) {
return false;
}
- const actualImportedFrom = importsMap[firstPart].moduleName;
+ const actualImportedFrom = effectiveImport.moduleName;
if (importedFrom instanceof RegExp) {
if (importedFrom.test(actualImportedFrom) === false) {
@@ -221,7 +252,7 @@ function scan({
const info = getInstanceInfo({
node,
filePath,
- importInfo: importsMap[firstPart],
+ importInfo: effectiveImport,
getPropValue,
componentName,
});
diff --git a/src/utils.js b/src/utils.js
index 26b9198..67b9984 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -94,6 +94,12 @@ function validateConfig(config, configDir) {
}
}
+ for (const hook of ["resolveImport", "resolveComponent"]) {
+ if (config[hook] !== undefined && typeof config[hook] !== "function") {
+ result.errors.push(`${hook} should be a function`);
+ }
+ }
+
if (config.processors !== undefined) {
if (Array.isArray(config.processors)) {
for (let i = 0, len = config.processors.length; i < len; i++) {