@supernovaio/cli
Version:
Supernova.io Command Line Interface
364 lines (362 loc) • 15.6 kB
JavaScript
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="568ae61e-7ec0-5d7f-85d1-4324f9871624")}catch(e){}}();
import { readFile } from "node:fs/promises";
import ts from "typescript";
import { collectFiles, componentKeyFrom, toRelative } from "./helpers.js";
const sourcePattern = "**/*.{ts,tsx,js,jsx}";
const excludedFilePatterns = [".stories.", ".test.", ".spec."];
const minFallbackModuleScore = 3;
function escapeForRegex(input) {
return input.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
}
function findImports(content, sourcePath) {
const imports = [];
const scriptKind = sourcePath.endsWith(".tsx")
? ts.ScriptKind.TSX
: sourcePath.endsWith(".jsx")
? ts.ScriptKind.JSX
: ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(sourcePath, content, ts.ScriptTarget.Latest, true, scriptKind);
sourceFile.forEachChild(node => {
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) {
return;
}
const { importClause } = node;
if (!importClause) {
return;
}
if (importClause.name) {
imports.push({
imported: "default",
local: importClause.name.getText(sourceFile),
moduleSpecifier: node.moduleSpecifier.text,
});
}
if (importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
for (const namedImport of importClause.namedBindings.elements) {
const imported = namedImport.propertyName
? namedImport.propertyName.getText(sourceFile)
: namedImport.name.getText(sourceFile);
const local = namedImport.name.getText(sourceFile);
imports.push({
imported,
local,
moduleSpecifier: node.moduleSpecifier.text,
});
}
}
});
return imports;
}
function findImportedLocalNames(content, importFrom, sourcePath) {
const importFromSet = new Set(Array.isArray(importFrom) ? importFrom : [importFrom]);
const isImportFromMatch = (moduleSpecifier) => {
for (const packageName of importFromSet) {
if (moduleSpecifier === packageName || moduleSpecifier.startsWith(`${packageName}/`)) {
return true;
}
}
return false;
};
return findImports(content, sourcePath)
.filter(binding => isImportFromMatch(binding.moduleSpecifier))
.map(binding => ({ imported: binding.imported, local: binding.local, moduleSpecifier: binding.moduleSpecifier }));
}
function resolveImportFromPackage(moduleSpecifier, importFromPackages) {
const normalizedPackages = [...new Set(importFromPackages.map(item => item.trim()).filter(Boolean))].sort((a, b) => b.length - a.length || a.localeCompare(b));
for (const packageName of normalizedPackages) {
if (moduleSpecifier === packageName || moduleSpecifier.startsWith(`${packageName}/`)) {
return packageName;
}
}
return undefined;
}
function lineAt(content, index) {
return content.slice(0, index).split("\n").length;
}
function extractJsxBlocks(content, localName, sourcePath) {
const escapedName = escapeForRegex(localName);
const tagPattern = new RegExp(`<\\/?${escapedName}\\b[^>]*>`, "g");
const tags = [...content.matchAll(tagPattern)];
const snippets = [];
for (let i = 0; i < tags.length; i++) {
const tag = tags[i];
const tagText = tag[0];
const startIndex = tag.index ?? 0;
if (tagText.startsWith("</")) {
continue;
}
if (tagText.endsWith("/>")) {
const endIndex = startIndex + tagText.length;
snippets.push({
lineEnd: lineAt(content, endIndex),
lineStart: lineAt(content, startIndex),
snippet: content.slice(startIndex, endIndex).trim(),
sourcePath,
});
continue;
}
let depth = 1;
let endIndex = startIndex + tagText.length;
for (let j = i + 1; j < tags.length; j++) {
const innerTag = tags[j];
const innerText = innerTag[0];
const innerIndex = innerTag.index ?? 0;
if (innerText.startsWith("</")) {
depth -= 1;
if (depth === 0) {
endIndex = innerIndex + innerText.length;
break;
}
continue;
}
if (!innerText.endsWith("/>")) {
depth += 1;
}
}
snippets.push({
lineEnd: lineAt(content, endIndex),
lineStart: lineAt(content, startIndex),
snippet: content.slice(startIndex, endIndex).trim(),
sourcePath,
});
}
return snippets;
}
function extractCallUsages(content, localName, sourcePath) {
const callPattern = new RegExp(`${escapeForRegex(localName)}\\s*\\(`, "g");
const snippets = [];
for (const match of content.matchAll(callPattern)) {
const index = match.index ?? 0;
const line = lineAt(content, index);
const lineText = content.split("\n")[line - 1] ?? "";
snippets.push({
lineEnd: line,
lineStart: line,
snippet: lineText.trim(),
sourcePath,
});
}
return snippets;
}
function normalizeValueText(input, maxLength = 120) {
const normalized = input.replaceAll(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1)}…`;
}
function extractJsxPropUsage(content, localName, sourcePath) {
const propUsage = new Map();
const scriptKind = sourcePath.endsWith(".tsx")
? ts.ScriptKind.TSX
: sourcePath.endsWith(".jsx")
? ts.ScriptKind.JSX
: ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(sourcePath, content, ts.ScriptTarget.Latest, true, scriptKind);
const getTagName = (name) => {
if (ts.isIdentifier(name)) {
return name.text;
}
if (ts.isPropertyAccessExpression(name)) {
return name.name.text;
}
return null;
};
const readJsxAttributeValue = (attribute) => {
if (!attribute.initializer) {
return "{true}";
}
if (ts.isStringLiteral(attribute.initializer)) {
return `"${normalizeValueText(attribute.initializer.text)}"`;
}
if (ts.isJsxExpression(attribute.initializer)) {
if (!attribute.initializer.expression) {
return "{}";
}
const { expression } = attribute.initializer;
if (ts.isStringLiteralLike(expression) ||
ts.isNumericLiteral(expression) ||
expression.kind === ts.SyntaxKind.TrueKeyword ||
expression.kind === ts.SyntaxKind.FalseKeyword ||
expression.kind === ts.SyntaxKind.NullKeyword) {
return normalizeValueText(expression.getText(sourceFile));
}
return `{${normalizeValueText(expression.getText(sourceFile))}}`;
}
return normalizeValueText(attribute.initializer.getText(sourceFile));
};
const addPropUsage = (propName, value) => {
const existing = propUsage.get(propName) ?? { count: 0, values: new Map() };
existing.count += 1;
existing.values.set(value, (existing.values.get(value) ?? 0) + 1);
propUsage.set(propName, existing);
};
const inspectOpeningLikeElement = (node) => {
const tagName = getTagName(node.tagName);
if (tagName !== localName) {
return;
}
for (const attribute of node.attributes.properties) {
if (ts.isJsxSpreadAttribute(attribute)) {
continue;
}
const propName = attribute.name.getText(sourceFile);
const propValue = readJsxAttributeValue(attribute);
addPropUsage(propName, propValue);
}
};
const visit = (node) => {
if (ts.isJsxSelfClosingElement(node)) {
inspectOpeningLikeElement(node);
}
else if (ts.isJsxOpeningElement(node)) {
inspectOpeningLikeElement(node);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return propUsage;
}
function mergePropUsage(target, incoming) {
const out = target ?? {};
for (const [propName, usage] of incoming.entries()) {
const existing = out[propName] ?? { count: 0, values: new Map() };
existing.count += usage.count;
for (const [value, valueCount] of usage.values.entries()) {
existing.values.set(value, (existing.values.get(value) ?? 0) + valueCount);
}
out[propName] = existing;
}
return out;
}
export async function analyzeComponentUsage(projectRoot, components, importFrom, excludePaths = []) {
const files = await collectFiles({
excludeNestedPackages: true,
excludePaths,
pattern: sourcePattern,
projectRoot,
});
const recordsByPackage = {};
const componentByExportName = new Map(components.map(component => [component.exportName, component]));
const inferredComponentsByExportName = new Map();
const inferredKeys = new Set();
const fallbackModuleScores = new Map();
const resolveComponent = (imported, local, moduleSpecifier) => {
const known = componentByExportName.get(imported);
if (known) {
return known;
}
const inferredName = imported === "default" ? local : imported;
if (!inferredName || inferredName === "default") {
return undefined;
}
const existing = inferredComponentsByExportName.get(inferredName);
if (existing) {
return existing;
}
const descriptor = {
componentKey: componentKeyFrom(inferredName, `${moduleSpecifier}:${inferredName}`, inferredKeys),
componentPath: moduleSpecifier,
exportName: inferredName,
};
inferredComponentsByExportName.set(inferredName, descriptor);
return descriptor;
};
const runScan = async (targetImportFrom) => {
const targetImportFromList = Array.isArray(targetImportFrom) ? targetImportFrom : [targetImportFrom];
for (const file of files) {
if (excludedFilePatterns.some(pattern => file.includes(pattern))) {
continue;
}
const relativePath = toRelative(projectRoot, file);
const content = await readFile(file, "utf8");
for (const binding of findImports(content, relativePath)) {
if (binding.moduleSpecifier.startsWith(".")) {
continue;
}
if (!componentByExportName.has(binding.imported)) {
continue;
}
fallbackModuleScores.set(binding.moduleSpecifier, (fallbackModuleScores.get(binding.moduleSpecifier) ?? 0) + 1);
}
const importBindings = findImportedLocalNames(content, targetImportFromList, relativePath);
if (importBindings.length === 0) {
continue;
}
for (const importBinding of importBindings) {
const importFromPackage = resolveImportFromPackage(importBinding.moduleSpecifier, targetImportFromList);
if (!importFromPackage) {
continue;
}
const component = resolveComponent(importBinding.imported, importBinding.local, importBinding.moduleSpecifier);
if (!component) {
continue;
}
const jsxSnippets = extractJsxBlocks(content, importBinding.local, relativePath);
const snippets = jsxSnippets.length > 0 ? jsxSnippets : extractCallUsages(content, importBinding.local, relativePath);
const propUsage = extractJsxPropUsage(content, importBinding.local, relativePath);
if (snippets.length === 0) {
continue;
}
const packageRecords = recordsByPackage[importFromPackage] ?? {};
const existing = packageRecords[component.componentKey] ?? {
count: 0,
files: [],
propUsage: {},
snippets: [],
};
existing.count += snippets.length;
existing.files.push(relativePath);
existing.snippets.push(...snippets);
existing.propUsage = mergePropUsage(existing.propUsage, propUsage);
packageRecords[component.componentKey] = existing;
recordsByPackage[importFromPackage] = packageRecords;
}
}
};
const importFromList = Array.isArray(importFrom) ? importFrom : [importFrom];
await runScan(importFromList);
const hasUsage = Object.values(recordsByPackage).some(packageRecords => Object.values(packageRecords).some(record => record.count > 0));
if (!hasUsage) {
const bestFallback = [...fallbackModuleScores.entries()]
.filter(([moduleName, score]) => !importFromList.includes(moduleName) && score >= minFallbackModuleScore)
.sort((a, b) => b[1] - a[1])[0];
if (bestFallback) {
await runScan(bestFallback[0]);
}
}
for (const packageRecords of Object.values(recordsByPackage)) {
for (const componentKey of Object.keys(packageRecords)) {
packageRecords[componentKey].files = [...new Set(packageRecords[componentKey].files)]
.sort((a, b) => a.localeCompare(b))
.slice(0, 30);
packageRecords[componentKey].snippets = packageRecords[componentKey].snippets.sort((a, b) => {
if (a.sourcePath === b.sourcePath) {
return a.lineStart - b.lineStart;
}
return a.sourcePath.localeCompare(b.sourcePath);
});
const currentPropUsage = packageRecords[componentKey].propUsage;
if (currentPropUsage) {
packageRecords[componentKey].propUsage = Object.fromEntries(Object.entries(currentPropUsage)
.sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]))
.map(([propName, usage]) => [
propName,
{
count: usage.count,
values: [...usage.values.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([value, count]) => ({ count, value })),
},
]));
}
}
}
const resolvedComponents = components.length > 0 ? components : [...inferredComponentsByExportName.values()];
return {
components: resolvedComponents.sort((a, b) => a.componentKey.localeCompare(b.componentKey)),
records: recordsByPackage,
};
}
//# sourceMappingURL=component-usage.js.map
//# debugId=568ae61e-7ec0-5d7f-85d1-4324f9871624