import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import generate from '@babel/generator'; import { parse } from '@babel/parser'; import traverseBabel from '@babel/traverse'; import { isJSXIdentifier, isJSXMemberExpression, } from '@babel/types'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..'); // Blacklist of components that should not be extracted (utility/non-visual components) const COMPONENT_BLACKLIST = new Set([ 'Helmet', 'HelmetProvider', 'Head', 'head', 'Meta', 'meta', 'Script', 'script', 'NoScript', 'noscript', 'Style', 'style', 'title', 'Title', 'link', 'Link', ]); /** * Validates that a file path is safe to access * @param {string} filePath - Relative file path * @returns {{ isValid: boolean, absolutePath?: string, error?: string }} - Object containing validation result */ export function validateFilePath(filePath) { if (!filePath) { return { isValid: false, error: 'Missing filePath' }; } const absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath); if (filePath.includes('..') || !absoluteFilePath.startsWith(VITE_PROJECT_ROOT) || absoluteFilePath.includes('node_modules')) { return { isValid: false, error: 'Invalid path' }; } if (!fs.existsSync(absoluteFilePath)) { return { isValid: false, error: 'File not found' }; } return { isValid: true, absolutePath: absoluteFilePath }; } /** * Parses a file into a Babel AST * @param {string} absoluteFilePath - Absolute path to file * @returns {object} Babel AST */ export function parseFileToAST(absoluteFilePath) { const content = fs.readFileSync(absoluteFilePath, 'utf-8'); return parse(content, { sourceType: 'module', plugins: ['jsx', 'typescript'], errorRecovery: true, }); } /** * Finds a JSX opening element at a specific line and column * @param {object} ast - Babel AST * @param {number} line - Line number (1-indexed) * @param {number} column - Column number (0-indexed for get-code-block, 1-indexed for apply-edit) * @returns {object | null} Babel path to the JSX opening element */ export function findJSXElementAtPosition(ast, line, column) { let targetNodePath = null; let closestNodePath = null; let closestDistance = Infinity; const allNodesOnLine = []; const visitor = { JSXOpeningElement(path) { const node = path.node; if (node.loc) { // Exact match (with tolerance for off-by-one column differences) if (node.loc.start.line === line && Math.abs(node.loc.start.column - column) <= 1) { targetNodePath = path; path.stop(); return; } // Track all nodes on the same line if (node.loc.start.line === line) { allNodesOnLine.push({ path, column: node.loc.start.column, distance: Math.abs(node.loc.start.column - column), }); } // Track closest match on the same line for fallback if (node.loc.start.line === line) { const distance = Math.abs(node.loc.start.column - column); if (distance < closestDistance) { closestDistance = distance; closestNodePath = path; } } } }, // Also check JSXElement nodes that contain the position JSXElement(path) { const node = path.node; if (!node.loc) { return; } // Check if this element spans the target line (for multi-line elements) if (node.loc.start.line > line || node.loc.end.line < line) { return; } // If we're inside this element's range, consider its opening element if (!path.node.openingElement?.loc) { return; } const openingLine = path.node.openingElement.loc.start.line; const openingCol = path.node.openingElement.loc.start.column; // Prefer elements that start on the exact line if (openingLine === line) { const distance = Math.abs(openingCol - column); if (distance < closestDistance) { closestDistance = distance; closestNodePath = path.get('openingElement'); } return; } // Handle elements that start before the target line if (openingLine < line) { const distance = (line - openingLine) * 100; // Penalize by line distance if (distance < closestDistance) { closestDistance = distance; closestNodePath = path.get('openingElement'); } } }, }; traverseBabel.default(ast, visitor); // Return exact match if found, otherwise return closest match if within reasonable distance // Use larger threshold (50 chars) for same-line elements, 5 lines for multi-line elements const threshold = closestDistance < 100 ? 50 : 500; return targetNodePath || (closestDistance <= threshold ? closestNodePath : null); } /** * Checks if a JSX element name is blacklisted * @param {object} jsxOpeningElement - Babel JSX opening element node * @returns {boolean} True if blacklisted */ function isBlacklistedComponent(jsxOpeningElement) { if (!jsxOpeningElement || !jsxOpeningElement.name) { return false; } // Handle JSXIdentifier (e.g., ) if (isJSXIdentifier(jsxOpeningElement.name)) { return COMPONENT_BLACKLIST.has(jsxOpeningElement.name.name); } // Handle JSXMemberExpression (e.g., ) if (isJSXMemberExpression(jsxOpeningElement.name)) { let current = jsxOpeningElement.name; while (isJSXMemberExpression(current)) { current = current.property; } if (isJSXIdentifier(current)) { return COMPONENT_BLACKLIST.has(current.name); } } return false; } /** * Generates code from an AST node * @param {object} node - Babel AST node * @param {object} options - Generator options * @returns {string} Generated code */ export function generateCode(node, options = {}) { const generateFunction = generate.default || generate; const output = generateFunction(node, options); return output.code; } /** * Generates a full source file from AST with source maps * @param {object} ast - Babel AST * @param {string} sourceFileName - Source file name for source map * @param {string} originalCode - Original source code * @returns {{code: string, map: object}} - Object containing generated code and source map */ export function generateSourceWithMap(ast, sourceFileName, originalCode) { const generateFunction = generate.default || generate; return generateFunction(ast, { sourceMaps: true, sourceFileName, }, originalCode); } /** * Extracts code blocks from a JSX element at a specific location * @param {string} filePath - Relative file path * @param {number} line - Line number * @param {number} column - Column number * @param {object} [domContext] - Optional DOM context to return on failure * @returns {{success: boolean, filePath?: string, specificLine?: string, error?: string, domContext?: object}} - Object with metadata for LLM */ export function extractCodeBlocks(filePath, line, column, domContext) { try { // Validate file path const validation = validateFilePath(filePath); if (!validation.isValid) { return { success: false, error: validation.error, domContext }; } // Parse AST const ast = parseFileToAST(validation.absolutePath); // Find target node const targetNodePath = findJSXElementAtPosition(ast, line, column); if (!targetNodePath) { return { success: false, error: 'Target node not found at specified line/column', domContext }; } // Check if the target node is a blacklisted component const isBlacklisted = isBlacklistedComponent(targetNodePath.node); if (isBlacklisted) { return { success: true, filePath, specificLine: '', }; } // Get specific line code const specificLine = generateCode(targetNodePath.parentPath?.node || targetNodePath.node); return { success: true, filePath, specificLine, }; } catch (error) { console.error('[ast-utils] Error extracting code blocks:', error); return { success: false, error: 'Failed to extract code blocks', domContext }; } } /** * Project root path */ export { VITE_PROJECT_ROOT };