UNPKG

@supernovaio/cli

Version:

Supernova.io Command Line Interface

364 lines (362 loc) 15.6 kB
!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