UNPKG

sicua

Version:

A tool for analyzing project structure and dependencies

420 lines (419 loc) 17.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PerformanceUtils = void 0; const typescript_1 = __importDefault(require("typescript")); const jsxUtils_1 = require("./jsxUtils"); /** * Utility functions for performance-related SEO analysis */ class PerformanceUtils { /** * Detects dynamic imports in a component */ static findDynamicImports(sourceFile) { const dynamicImports = []; const visitNode = (node) => { // Check for React.lazy() if (typescript_1.default.isCallExpression(node)) { const expression = node.expression; // React.lazy(() => import('...')) if (typescript_1.default.isPropertyAccessExpression(expression) && typescript_1.default.isIdentifier(expression.expression) && expression.expression.text === "React" && expression.name.text === "lazy") { const lazyArg = node.arguments[0]; if (typescript_1.default.isArrowFunction(lazyArg)) { const importCall = this.findImportInExpression(lazyArg.body); if (importCall) { dynamicImports.push({ importPath: importCall, isNextDynamic: false, hasSSR: true, // React.lazy defaults to SSR hasLoading: false, }); } } } // dynamic() from next/dynamic if (typescript_1.default.isIdentifier(expression) && expression.text === "dynamic") { const dynamicArg = node.arguments[0]; const optionsArg = node.arguments[1]; let importPath = ""; let hasSSR = true; // default let hasLoading = false; // Extract import path if (typescript_1.default.isArrowFunction(dynamicArg)) { const importCall = this.findImportInExpression(dynamicArg.body); if (importCall) { importPath = importCall; } } // Check options if (optionsArg && typescript_1.default.isObjectLiteralExpression(optionsArg)) { optionsArg.properties.forEach((prop) => { if (typescript_1.default.isPropertyAssignment(prop) && typescript_1.default.isIdentifier(prop.name)) { if (prop.name.text === "ssr") { hasSSR = prop.initializer.kind === typescript_1.default.SyntaxKind.TrueKeyword; } if (prop.name.text === "loading") { hasLoading = true; } } }); } if (importPath) { dynamicImports.push({ importPath, isNextDynamic: true, hasSSR, hasLoading, }); } } } typescript_1.default.forEachChild(node, visitNode); }; visitNode(sourceFile); return dynamicImports; } /** * Helper to find import() calls in expressions */ static findImportInExpression(expression) { if (typescript_1.default.isCallExpression(expression) && expression.expression.kind === typescript_1.default.SyntaxKind.ImportKeyword) { const importArg = expression.arguments[0]; if (typescript_1.default.isStringLiteral(importArg)) { return importArg.text; } } if (typescript_1.default.isBlock(expression)) { for (const statement of expression.statements) { if (typescript_1.default.isReturnStatement(statement) && statement.expression) { return this.findImportInExpression(statement.expression); } } } return null; } /** * Analyzes static imports that might impact performance */ static analyzeStaticImports(sourceFile) { const imports = []; // Known large libraries that should be dynamically imported const largeLibraries = [ "lodash", "moment", "chart.js", "three", "@tensorflow/tfjs", "monaco-editor", "codemirror", "highlight.js", "prismjs", "pdf-lib", "fabric", ]; const visitNode = (node) => { if (typescript_1.default.isImportDeclaration(node) && typescript_1.default.isStringLiteral(node.moduleSpecifier)) { const importPath = node.moduleSpecifier.text; const isLibrary = !importPath.startsWith(".") && !importPath.startsWith("/"); const isLargeLibrary = largeLibraries.some((lib) => importPath === lib || importPath.startsWith(`${lib}/`)); const isImageImport = /\.(jpg|jpeg|png|gif|svg|webp)$/.test(importPath); let importType = "side-effect"; if (node.importClause) { if (node.importClause.name) { importType = "default"; } else if (node.importClause.namedBindings) { if (typescript_1.default.isNamespaceImport(node.importClause.namedBindings)) { importType = "namespace"; } else { importType = "named"; } } } imports.push({ importPath, isLibrary, isLargeLibrary, isImageImport, importType, }); } typescript_1.default.forEachChild(node, visitNode); }; visitNode(sourceFile); return imports; } /** * Detects potential Core Web Vitals issues in JSX */ static detectCWVIssues(sourceFile) { const issues = []; const visitNode = (node) => { if (typescript_1.default.isJsxElement(node) || typescript_1.default.isJsxSelfClosingElement(node)) { const tagName = jsxUtils_1.JsxUtils.getTagName(node).toLowerCase(); const issues_found = this.analyzeElementForCWV(node, tagName); issues.push(...issues_found); } typescript_1.default.forEachChild(node, visitNode); }; visitNode(sourceFile); return issues; } /** * Analyze a JSX element for potential CWV issues */ static analyzeElementForCWV(node, tagName) { const issues = []; const location = `Line ${node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()).line + 1}`; // LCP (Largest Contentful Paint) issues if (tagName === "img") { const src = jsxUtils_1.JsxUtils.getAttribute(node, "src"); const width = jsxUtils_1.JsxUtils.getAttribute(node, "width"); const height = jsxUtils_1.JsxUtils.getAttribute(node, "height"); const priority = jsxUtils_1.JsxUtils.getAttribute(node, "priority"); // Missing dimensions can cause CLS if (!width || !height) { issues.push({ type: "CLS", severity: "medium", description: "Image missing width or height attributes can cause layout shift", location, element: "img", }); } // Large images without priority if (src && !priority && this.isLikelyAboveFold(node)) { issues.push({ type: "LCP", severity: "high", description: "Above-the-fold image should have priority attribute for better LCP", location, element: "img", }); } } // Next.js Image component analysis if (tagName === "Image") { const priority = jsxUtils_1.JsxUtils.getAttribute(node, "priority"); const placeholder = jsxUtils_1.JsxUtils.getAttribute(node, "placeholder"); const sizes = jsxUtils_1.JsxUtils.getAttribute(node, "sizes"); if (!priority && this.isLikelyAboveFold(node)) { issues.push({ type: "LCP", severity: "high", description: "Above-the-fold Next.js Image should have priority prop", location, element: "Image", }); } if (!sizes) { issues.push({ type: "LCP", severity: "medium", description: "Next.js Image missing sizes prop may cause suboptimal loading", location, element: "Image", }); } } // FID/INP issues - large click handlers const onClick = jsxUtils_1.JsxUtils.getAttribute(node, "onClick"); if (onClick && this.isComplexEventHandler(node)) { issues.push({ type: "INP", severity: "medium", description: "Complex event handler may impact interaction responsiveness", location, element: tagName, }); } // CLS issues - elements without dimensions if (["iframe", "embed", "object"].includes(tagName)) { const width = jsxUtils_1.JsxUtils.getAttribute(node, "width"); const height = jsxUtils_1.JsxUtils.getAttribute(node, "height"); if (!width || !height) { issues.push({ type: "CLS", severity: "high", description: `${tagName} without dimensions can cause significant layout shift`, location, element: tagName, }); } } // Font loading issues if (tagName === "link") { const rel = jsxUtils_1.JsxUtils.getAttribute(node, "rel"); const href = jsxUtils_1.JsxUtils.getAttribute(node, "href"); if (rel === "stylesheet" && href && href.includes("fonts.googleapis.com")) { const preconnect = this.hasPreconnectForGoogleFonts(node); if (!preconnect) { issues.push({ type: "LCP", severity: "medium", description: "Google Fonts loading without preconnect may impact LCP", location, element: "link", }); } } } return issues; } /** * Determines if an element is likely above the fold */ static isLikelyAboveFold(node) { // Simple heuristic: check if it's in the first few elements of the component // In a real implementation, this could be more sophisticated const sourceFile = node.getSourceFile(); const componentStart = this.findComponentStart(sourceFile); const elementPosition = node.getStart(); // If element appears within first 20% of component, consider it above fold if (componentStart) { const componentLength = sourceFile.getEnd() - componentStart; const elementOffset = elementPosition - componentStart; return elementOffset / componentLength < 0.2; } return false; } /** * Find the start of the main component in the source file */ static findComponentStart(sourceFile) { let componentStart = null; const visitNode = (node) => { // Look for function components or class components if ((typescript_1.default.isFunctionDeclaration(node) || typescript_1.default.isArrowFunction(node) || typescript_1.default.isFunctionExpression(node)) && !componentStart) { // Check if this function returns JSX const hasJSXReturn = this.functionReturnsJSX(node); if (hasJSXReturn) { componentStart = node.getStart(); } } if (!componentStart) { typescript_1.default.forEachChild(node, visitNode); } }; visitNode(sourceFile); return componentStart; } /** * Check if a function returns JSX */ static functionReturnsJSX(node) { const body = node.body; if (!body) { return false; } if (typescript_1.default.isBlock(body)) { // Look for return statements with JSX return body.statements.some((statement) => { if (typescript_1.default.isReturnStatement(statement) && statement.expression) { return this.isJSXExpression(statement.expression); } return false; }); } else { // Arrow function with expression body return this.isJSXExpression(body); } } /** * Check if an expression is JSX */ static isJSXExpression(expression) { return (typescript_1.default.isJsxElement(expression) || typescript_1.default.isJsxSelfClosingElement(expression) || typescript_1.default.isJsxFragment(expression)); } /** * Check if an event handler is complex (potential performance issue) */ static isComplexEventHandler(node) { // This is a simplified check - in practice, you'd analyze the handler function // For now, just check if there are multiple event handlers or complex props const attributes = typescript_1.default.isJsxElement(node) ? node.openingElement.attributes.properties : node.attributes.properties; const eventHandlers = attributes.filter((attr) => typescript_1.default.isJsxAttribute(attr) && attr.name.getText().startsWith("on")); return eventHandlers.length > 2; // Arbitrary threshold } /** * Check if there's a preconnect for Google Fonts */ static hasPreconnectForGoogleFonts(node) { // This would need to check the document head for preconnect links // For static analysis, we can't fully determine this, so we return false // to encourage adding preconnect links return false; } /** * Analyze bundle impact of imports */ static analyzeBundleImpact(imports) { const heavyImports = []; const treeshakingIssues = []; imports.forEach((imp) => { if (imp.isLargeLibrary) { heavyImports.push(imp.importPath); } // Check for imports that prevent tree shaking if (imp.importType === "namespace" && imp.isLibrary) { treeshakingIssues.push(imp.importPath); } }); // Calculate bundle score (0-100) let bundleScore = 100; bundleScore -= heavyImports.length * 15; // -15 per heavy import bundleScore -= treeshakingIssues.length * 10; // -10 per tree shaking issue return { heavyImports, treeshakingIssues, bundleScore: Math.max(0, bundleScore), }; } /** * Check if component is server or client component */ static isServerComponent(sourceFile) { let hasUseClientDirective = false; let hasUseServerDirective = false; const visitNode = (node) => { if (typescript_1.default.isStringLiteral(node)) { if (node.text === "use client") { hasUseClientDirective = true; } if (node.text === "use server") { hasUseServerDirective = true; } } typescript_1.default.forEachChild(node, visitNode); }; visitNode(sourceFile); // In App Router, components are server components by default unless marked with 'use client' const isServerComponent = !hasUseClientDirective; return { isServerComponent, hasUseClientDirective, hasUseServerDirective, }; } } exports.PerformanceUtils = PerformanceUtils;