UNPKG

embedia

Version:

Zero-configuration AI chatbot integration CLI - direct file copy with embedded API keys

785 lines (661 loc) 26.7 kB
const BaseAdapter = require('../BaseAdapter'); const ASTModifier = require('../../ast/astModifier'); const ImprovedASTModifier = require('../../ast/improvedAstModifier'); const LayoutDetector = require('../../analysis/layoutDetector'); const path = require('path'); const fs = require('fs-extra'); class NextJSAdapter extends BaseAdapter { constructor(projectPath, framework, projectAnalysis) { super(projectPath, framework); this.astModifier = new ASTModifier(); this.improvedAstModifier = new ImprovedASTModifier(); this.layoutDetector = new LayoutDetector(); this.projectAnalysis = projectAnalysis; } getFileExtension(forAPI = false) { // Use TypeScript extensions if TypeScript is detected const isTypeScript = this.projectAnalysis?.typescript?.isTypeScript || false; if (forAPI) { return isTypeScript ? '.ts' : '.js'; } else { return isTypeScript ? '.tsx' : '.jsx'; } } async integrate(embediaFiles) { const routerType = await this.detectRouterType(); const componentType = embediaFiles.componentType || 'react'; console.log(`🔍 Detected Next.js ${this.framework.majorVersion} with ${routerType} router`); console.log(`🔍 Component type: ${componentType === 'webcomponent' ? 'Web Component' : 'React Component'}\n`); if (routerType === 'app') { return await this.integrateAppRouter(embediaFiles); } else if (routerType === 'pages') { return await this.integratePagesRouter(embediaFiles); } else { return await this.integrateHybrid(embediaFiles); } } async detectRouterType() { const hasApp = await this.fileExists('app') || await this.fileExists('src/app'); const hasPages = await this.fileExists('pages') || await this.fileExists('src/pages'); if (hasApp && !hasPages) return 'app'; if (!hasApp && hasPages) return 'pages'; if (hasApp && hasPages) return 'hybrid'; return 'unknown'; } async integrateAppRouter(embediaFiles) { const results = { success: false, framework: 'next', router: 'app', files: [], errors: [], instructions: [] }; try { const componentType = embediaFiles.componentType || 'react'; if (componentType === 'webcomponent') { // Handle web component integration await this.integrateWebComponent(embediaFiles, results); } else { // Handle React component integration (legacy) await this.createComponentFiles(embediaFiles); results.files.push('components/generated/embedia-chat/*'); // Find or create layout for React components await this.integrateReactComponent(embediaFiles, results); } // Create API route (works for both component types) const apiPath = await this.createAppRouterAPI(embediaFiles); results.files.push(apiPath); results.success = true; } catch (error) { results.errors.push({ type: 'integration_error', message: error.message }); } return results; } async integrateWebComponent(embediaFiles, results) { // Copy web component to public directory const publicDir = path.join(this.projectPath, 'public'); await fs.ensureDir(publicDir); const webComponentPath = path.join(publicDir, 'embedia-chatbot.js'); await fs.writeFile(webComponentPath, embediaFiles.component); results.files.push('public/embedia-chatbot.js'); // Copy example HTML if provided if (embediaFiles.exampleHtml) { const examplePath = path.join(publicDir, 'embedia-example.html'); await fs.writeFile(examplePath, embediaFiles.exampleHtml); results.files.push('public/embedia-example.html'); } // Find layout files and modify them for web component const layouts = await this.layoutDetector.findLayoutFiles(this.projectPath, 'app'); if (layouts.length > 0) { // Modify existing layout for web component const layout = layouts[0]; const modResult = await this.modifyFile(layout.path, async (content) => { return await this.modifyLayoutForWebComponent(content, layout.isTypeScript); }); if (modResult.success) { results.files.push(layout.path); results.instructions.push( 'Web component integrated successfully!', 'The chatbot will appear automatically on all pages.', 'You can customize it by editing public/embedia-chatbot.js' ); } else { results.errors.push({ type: 'layout_modification', message: 'Could not automatically modify layout for web component', suggestion: 'Add the web component manually to your layout' }); results.instructions.push( 'Manual integration required:', '1. Add this import to your app/layout.tsx (or .js):', " import { useEffect } from 'react'", '', '2. Add this useEffect hook inside your RootLayout component:', '', 'useEffect(() => {', ' const script = document.createElement("script");', ' script.src = "/embedia-chatbot.js";', ' document.head.appendChild(script);', '}, []);', '', '3. Add this element before closing </body>:', '<embedia-chatbot bot-id="your-bot-id"></embedia-chatbot>' ); } } else { // Create new layout with web component const isTS = await this.isTypeScriptProject(); const layoutPath = await this.createAppRouterLayoutWithWebComponent(isTS, embediaFiles); results.files.push(layoutPath); results.instructions.push( 'New layout created with web component integration', 'The chatbot will appear automatically on all pages' ); } } async integrateReactComponent(embediaFiles, results) { // Find or create layout for React components (original logic) const layouts = await this.layoutDetector.findLayoutFiles(this.projectPath, 'app'); if (layouts.length > 0) { // Modify existing layout const layout = layouts[0]; const modResult = await this.modifyFile(layout.path, async (content) => { try { // Try improved AST modifier first return await this.improvedAstModifier.modifyAppRouterLayout( content, layout.isTypeScript ); } catch (improvedError) { // Fallback to original AST modifier try { return await this.astModifier.modifyLayout( layout.path, content, layout.isTypeScript, true // isAppRouter ); } catch (originalError) { // If both fail, throw the improved error (more descriptive) throw improvedError; } } }); if (modResult.success) { results.files.push(layout.path); } else { results.errors.push({ type: 'layout_modification', message: modResult.error, suggestion: modResult.suggestion }); // Create a simple loader component as fallback const loaderPath = await this.createEmbediaChatLoader(); results.files.push(loaderPath); // Provide simpler manual integration instructions results.instructions.push( 'Automatic layout modification failed. Please add Embedia Chat manually:', '', '1. Import the loader component in your layout file:', ` import EmbediaChatLoader from '@/components/EmbediaChatLoader'`, '', '2. Add the component before closing </body> tag:', ' <EmbediaChatLoader />', '', 'That\'s it! The chat widget will appear when you refresh.' ); } } else { // Create new layout const isTS = await this.isTypeScriptProject(); const layoutPath = await this.createAppRouterLayout(isTS); results.files.push(layoutPath); } results.instructions.push( 'Add your AI provider API key to .env.local', 'Start your dev server to see the chat widget' ); } async modifyLayoutForWebComponent(content, isTypeScript) { // Simple string replacement for web component integration const useEffectImportPattern = /import.*useEffect.*from ['"]react['"]/; const hasUseEffect = useEffectImportPattern.test(content); let modifiedContent = content; // Add useEffect import if not present if (!hasUseEffect) { if (isTypeScript) { modifiedContent = modifiedContent.replace( /import\s+React/, 'import React, { useEffect }' ); } else { modifiedContent = modifiedContent.replace( /import.*from ['"]react['"]/, (match) => match.replace('from', ', useEffect } from') ); } } // Add web component script loading logic const webComponentScript = ` useEffect(() => { const script = document.createElement('script'); script.src = '/embedia-chatbot.js'; script.async = true; document.head.appendChild(script); return () => { // Cleanup if needed const existingScript = document.querySelector('script[src="/embedia-chatbot.js"]'); if (existingScript) { document.head.removeChild(existingScript); } }; }, []);`; // Add the useEffect after the RootLayout function starts const functionPattern = /export\s+default\s+function\s+\w+Layout\s*\([^)]*\)\s*{/; modifiedContent = modifiedContent.replace(functionPattern, (match) => { return match + webComponentScript; }); // Add the web component element before closing body tag const webComponentElement = '\n <embedia-chatbot></embedia-chatbot>'; modifiedContent = modifiedContent.replace('</body>', webComponentElement + '\n </body>'); return modifiedContent; } async createAppRouterLayoutWithWebComponent(isTypeScript, embediaFiles) { const ext = isTypeScript ? 'tsx' : 'jsx'; const layoutPath = path.join(this.projectPath, 'app', `layout.${ext}`); const botId = embediaFiles.config?.botId || 'embedia-chatbot'; const layoutContent = `import './globals.css' import { Inter } from 'next/font/google' ${isTypeScript ? "import { Metadata } from 'next'" : ''} import { useEffect } from 'react' const inter = Inter({ subsets: ['latin'] }) ${isTypeScript ? `export const metadata: Metadata = { title: 'Your App', description: 'Generated by Embedia', }` : ''} export default function RootLayout({ children, }${isTypeScript ? ': {\n children: React.ReactNode\n}' : ''}) { useEffect(() => { const script = document.createElement('script'); script.src = '/embedia-chatbot.js'; script.async = true; document.head.appendChild(script); return () => { const existingScript = document.querySelector('script[src="/embedia-chatbot.js"]'); if (existingScript) { document.head.removeChild(existingScript); } }; }, []); return ( <html lang="en"> <body className={inter.className}> {children} <embedia-chatbot bot-id="${botId}"></embedia-chatbot> </body> </html> ) } `; await fs.ensureDir(path.dirname(layoutPath)); await fs.writeFile(layoutPath, layoutContent); return layoutPath; } async createAppRouterAPI(embediaFiles) { // CRITICAL FIX: Ensure API route prevents 404 errors const apiContent = embediaFiles.apiRouteApp || embediaFiles.apiRoute; // Validate API content has required exports if (!apiContent.includes('export async function POST')) { console.log(chalk.yellow('⚠️ App Router API missing POST export - adding default')); const fixedContent = apiContent + '\n\nexport async function POST(request) {\n return Response.json({ error: "API not configured" }, { status: 500 });\n}'; embediaFiles.apiRouteApp = fixedContent; } const apiDir = path.join(this.projectPath, 'app', 'api', 'embedia', 'chat'); const extension = this.getFileExtension(true); const apiPath = path.join(apiDir, `route${extension}`); await fs.ensureDir(apiDir); await fs.writeFile(apiPath, embediaFiles.apiRouteApp || apiContent); // Verify API route was created correctly if (await fs.pathExists(apiPath)) { console.log(chalk.gray(` ✓ API route created: /api/embedia/chat`)); } else { console.log(chalk.red(` ❌ Failed to create API route at ${apiPath}`)); } return `app/api/embedia/chat/route${extension}`; } async integratePagesRouter(embediaFiles) { const results = { success: false, framework: 'next', router: 'pages', files: [], errors: [], instructions: [] }; try { const componentType = embediaFiles.componentType || 'react'; if (componentType === 'webcomponent') { // Handle web component integration for Pages Router await this.integrateWebComponentPages(embediaFiles, results); } else { // Handle React component integration (legacy) await this.createComponentFiles(embediaFiles); results.files.push('components/generated/embedia-chat/*'); // Find or create _app file for React components await this.integrateReactComponentPages(embediaFiles, results); } // Create API route (works for both component types) const apiPath = await this.createPagesRouterAPI(embediaFiles); results.files.push(apiPath); results.success = true; } catch (error) { results.errors.push({ type: 'integration_error', message: error.message }); } return results; } async integrateWebComponentPages(embediaFiles, results) { // Copy web component to public directory const publicDir = path.join(this.projectPath, 'public'); await fs.ensureDir(publicDir); const webComponentPath = path.join(publicDir, 'embedia-chatbot.js'); await fs.writeFile(webComponentPath, embediaFiles.component); results.files.push('public/embedia-chatbot.js'); // Copy example HTML if provided if (embediaFiles.exampleHtml) { const examplePath = path.join(publicDir, 'embedia-example.html'); await fs.writeFile(examplePath, embediaFiles.exampleHtml); results.files.push('public/embedia-example.html'); } // Find or create _app file for web component loading const layouts = await this.layoutDetector.findLayoutFiles(this.projectPath, 'pages'); if (layouts.length > 0) { // Modify existing _app for web component const app = layouts[0]; const modResult = await this.modifyFile(app.path, async (content) => { return await this.modifyAppForWebComponent(content, app.isTypeScript); }); if (modResult.success) { results.files.push(app.path); results.instructions.push( 'Web component integrated successfully!', 'The chatbot will appear automatically on all pages.', 'You can customize it by editing public/embedia-chatbot.js' ); } else { results.instructions.push( 'Manual integration required:', '1. Add this import to your pages/_app.tsx (or .js):', " import { useEffect } from 'react'", '', '2. Add this useEffect hook inside your MyApp component:', '', 'useEffect(() => {', ' const script = document.createElement("script");', ' script.src = "/embedia-chatbot.js";', ' document.body.appendChild(script);', '}, []);', '', '3. Add this to your layout or _document.js:', '<embedia-chatbot bot-id="your-bot-id"></embedia-chatbot>' ); } } else { // Create new _app with web component const isTS = await this.isTypeScriptProject(); const appPath = await this.createPagesAppWithWebComponent(isTS, embediaFiles); results.files.push(appPath); results.instructions.push( 'New _app created with web component integration', 'The chatbot will appear automatically on all pages' ); } } async integrateReactComponentPages(embediaFiles, results) { // Find or create _app file for React components (original logic) const layouts = await this.layoutDetector.findLayoutFiles(this.projectPath, 'pages'); if (layouts.length > 0) { // Modify existing _app const app = layouts[0]; const modResult = await this.modifyFile(app.path, async (content) => { return this.modifyPagesApp(content, app.isTypeScript); }); if (modResult.success) { results.files.push(app.path); } else { results.errors.push({ type: 'app_modification', message: modResult.error, suggestion: modResult.suggestion }); } } else { // Create new _app const isTS = await this.isTypeScriptProject(); const appPath = await this.createPagesApp(isTS); results.files.push(appPath); } results.instructions.push( 'Add your AI provider API key to .env.local', 'Start your dev server to see the chat widget' ); } async modifyAppForWebComponent(content, isTypeScript) { // Simple string replacement for web component integration in _app const useEffectImportPattern = /import.*useEffect.*from ['"]react['"]/; const hasUseEffect = useEffectImportPattern.test(content); let modifiedContent = content; // Add useEffect import if not present if (!hasUseEffect) { modifiedContent = modifiedContent.replace( /import.*from ['"]react['"]/, (match) => match.replace('from', ', useEffect } from') ); } // Add web component script loading logic const webComponentScript = ` useEffect(() => { const script = document.createElement('script'); script.src = '/embedia-chatbot.js'; script.async = true; document.head.appendChild(script); return () => { const existingScript = document.querySelector('script[src="/embedia-chatbot.js"]'); if (existingScript) { document.head.removeChild(existingScript); } }; }, []);`; // Add the useEffect after the MyApp function starts const functionPattern = /export\s+default\s+function\s+\w+App\s*\([^)]*\)\s*{/; modifiedContent = modifiedContent.replace(functionPattern, (match) => { return match + webComponentScript; }); // Add the web component element in the return statement const returnPattern = /return\s*\(\s*<([^>]+)>([\s\S]*?)<\/\1>\s*\)/; modifiedContent = modifiedContent.replace(returnPattern, (match, tag, content) => { return `return ( <${tag}> ${content.trim()} <embedia-chatbot></embedia-chatbot> </${tag}> )`; }); return modifiedContent; } async createPagesAppWithWebComponent(isTypeScript, embediaFiles) { const ext = isTypeScript ? 'tsx' : 'jsx'; const appPath = path.join(this.projectPath, 'pages', `_app.${ext}`); const botId = embediaFiles.config?.botId || 'embedia-chatbot'; const appContent = `import type { AppProps } from 'next/app' import { useEffect } from 'react' export default function MyApp({ Component, pageProps }${isTypeScript ? ': AppProps' : ''}) { useEffect(() => { const script = document.createElement('script'); script.src = '/embedia-chatbot.js'; script.async = true; document.head.appendChild(script); return () => { const existingScript = document.querySelector('script[src="/embedia-chatbot.js"]'); if (existingScript) { document.head.removeChild(existingScript); } }; }, []); return ( <> <Component {...pageProps} /> <embedia-chatbot bot-id="${botId}"></embedia-chatbot> </> ) } `; await fs.ensureDir(path.dirname(appPath)); await fs.writeFile(appPath, appContent); return `pages/_app.${ext}`; } async createPagesRouterAPI(embediaFiles) { // CRITICAL FIX: Ensure API route prevents 404 errors const apiContent = embediaFiles.apiRoutePages || embediaFiles.apiRoute; // Validate API content has required exports if (!apiContent.includes('export default')) { console.log(chalk.yellow('⚠️ Pages Router API missing default export - adding default')); const fixedContent = apiContent + '\n\nexport default function handler(req, res) {\n res.status(500).json({ error: "API not configured" });\n}'; embediaFiles.apiRoutePages = fixedContent; } const apiDir = path.join(this.projectPath, 'pages', 'api', 'embedia'); const extension = this.getFileExtension(true); const apiPath = path.join(apiDir, `chat${extension}`); await fs.ensureDir(apiDir); await fs.writeFile(apiPath, embediaFiles.apiRoutePages || apiContent); // Verify API route was created correctly if (await fs.pathExists(apiPath)) { console.log(chalk.gray(` ✓ API route created: /api/embedia/chat`)); } else { console.log(chalk.red(` ❌ Failed to create API route at ${apiPath}`)); } return `pages/api/embedia/chat${extension}`; } async integrateHybrid(embediaFiles) { // For hybrid apps, prefer app router console.log('📦 Hybrid Next.js app detected, integrating with App Router\n'); return await this.integrateAppRouter(embediaFiles); } async createAppRouterLayout(isTypeScript) { const layoutDir = await this.fileExists('src/app') ? 'src/app' : 'app'; const ext = isTypeScript ? 'tsx' : 'jsx'; const layoutPath = `${layoutDir}/layout.${ext}`; const layoutContent = `import './globals.css' import Script from 'next/script' export const metadata = { title: 'My App', description: 'Enhanced with Embedia Chat', } export default function RootLayout({ children }${isTypeScript ? ': { children: React.ReactNode }' : ''}) { return ( <html lang="en"> <body> {children} {/* Embedia Chat Integration */} <embedia-chatbot></embedia-chatbot> <Script src="/embedia-chatbot.js" /> </body> </html> ) }`; await this.createFile(layoutPath, layoutContent); return layoutPath; } async createPagesApp(isTypeScript) { const appDir = await this.fileExists('src/pages') ? 'src/pages' : 'pages'; const ext = isTypeScript ? 'tsx' : 'jsx'; const appPath = `${appDir}/_app.${ext}`; const imports = isTypeScript ? "import type { AppProps } from 'next/app'\n" : ''; const props = isTypeScript ? ': AppProps' : ''; const appContent = `${imports}import { useEffect } from 'react' function MyApp({ Component, pageProps }${props}) { useEffect(() => { if (typeof window !== 'undefined') { import('/components/generated/embedia-chat/index.js').then((module) => { const EmbediaChat = module.default || module.EmbediaChat; if (!document.getElementById('embedia-chat-root')) { const container = document.createElement('div'); container.id = 'embedia-chat-root'; document.body.appendChild(container); // Simplified mounting logic here } }).catch(console.error); } }, []); return <Component {...pageProps} /> } export default MyApp`; await this.createFile(appPath, appContent); return appPath; } async createAppRouterAPI(apiRoute) { const apiDir = await this.fileExists('src/app') ? 'src/app/api' : 'app/api'; const apiPath = `${apiDir}/embedia/chat/route.js`; await this.createFile(apiPath, apiRoute); return apiPath; } async createPagesAPI(apiRoute) { const apiDir = await this.fileExists('src/pages') ? 'src/pages/api' : 'pages/api'; const apiPath = `${apiDir}/embedia/chat.js`; // Convert app router API to pages router format const pagesAPI = this.convertToPagesAPI(apiRoute); await this.createFile(apiPath, pagesAPI); return apiPath; } convertToPagesAPI(appRouterAPI) { // Simple conversion - this would need to be more sophisticated for complex APIs return appRouterAPI.replace( 'export async function POST(request)', 'export default async function handler(req, res)' ).replace( 'await request.json()', 'req.body' ).replace( 'return Response.json', 'res.status(200).json' ).replace( 'return new Response', 'res.status(500).send' ); } modifyPagesApp(content, isTypeScript) { // Insert useEffect into existing _app const importRegex = /import[^;]+;/g; const imports = content.match(importRegex) || []; // Add useEffect import if not present if (!imports.some(imp => imp.includes('useEffect'))) { content = "import { useEffect } from 'react'\n" + content; } // Find the component function const componentRegex = /function\s+\w+\s*\([^)]*\)\s*{|const\s+\w+\s*=\s*\([^)]*\)\s*=>\s*{/; const match = content.match(componentRegex); if (match) { const insertPosition = match.index + match[0].length; const integration = ` useEffect(() => { if (typeof window !== 'undefined') { import('/components/generated/embedia-chat/index.js').then((module) => { const EmbediaChat = module.default || module.EmbediaChat; if (!document.getElementById('embedia-chat-root')) { const container = document.createElement('div'); container.id = 'embedia-chat-root'; document.body.appendChild(container); // Simplified mounting logic here } }).catch(console.error); } }, []); `; content = content.slice(0, insertPosition) + integration + content.slice(insertPosition); } return content; } async createEmbediaChatLoader() { const loaderContent = `'use client' import { useEffect } from 'react' export default function EmbediaChatLoader() { useEffect(() => { // Simplified loading logic }, []); return <embedia-chatbot></embedia-chatbot>; }`; const isTS = await this.isTypeScriptProject(); const ext = isTS ? 'tsx' : 'jsx'; const loaderPath = `components/EmbediaChatLoader.${ext}`; await this.createFile(loaderPath, loaderContent); return loaderPath; } } module.exports = NextJSAdapter;