UNPKG

@salesforce/pwa-kit-mcp

Version:

MCP server that helps you build Salesforce Commerce Cloud PWA Kit Composable Storefront

265 lines (250 loc) 13.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _promises = _interopRequireDefault(require("fs/promises")); var _path = _interopRequireDefault(require("path")); var _utils = require("../utils"); var _zod = require("zod"); var _constants = require("../utils/constants"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); } function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; } /* * Copyright (c) 2025, Salesforce, Inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ const systemPromptForCreatePage = `You are a smart assistant that can use tools when needed. \ Please ask the user to provide following information **one at a time**, in a natural and conversational way. \ Do **not** ask all the questions at once. \ Do **not** assume the answers to the questions, especially the URL route. **Always** ask the user for the URL route. \ - What is the name of the new page to create? \ - List the components to include on the page, separated by commas. Component names should be in PascalCase (e.g., Image, ProductView) \ - What is the URL route for this page? (e.g., /new-home, /my-products) \ Collect answers to these questions, then call the tool with the collected information as input parameters.`; const systemPromptForUnfoundComponents = unfoundComponents => `The following components were not found: ${unfoundComponents.join(', ')}. \ If the component is not found, **Please** suggest changes to the newly generated page file based on the components not found.`; class CreateNewPageTool { constructor() { var _this = this; this.name = 'pwakit_create_page'; this.description = `Create a new ${_constants.PWA_KIT_DESCRIPTIVE_NAME} page. Gather information from user for the MCP tool parameters **one at a time**, in a natural and conversational way. Do **not** ask all the questions at once.`; this.inputSchema = { pageName: _zod.z.string().describe('The name of the new page to create'), componentList: _zod.z.array(_zod.z.string()).describe('The existing components to include on the page, separated by commas. Component names should be in PascalCase (e.g., AddressDisplay, ProductView, Footer)'), route: _zod.z.string().describe('The URL route for this page (e.g., /new-home, /my-product-view)') }; this.unfoundComponents = []; this.handler = /*#__PURE__*/function () { var _ref = _asyncToGenerator(function* (args) { if (!args || !args.pageName || !args.componentList || !args.route) { return { content: [{ type: 'text', text: systemPromptForCreatePage }] }; } try { const absolutePaths = yield (0, _utils.detectWorkspacePaths)(); (0, _utils.logMCPMessage)(`Detected workspace paths: ${JSON.stringify(absolutePaths)}`); return _this.createPage(args.pageName, args.componentList, args.route, absolutePaths); } catch (error) { (0, _utils.logMCPMessage)(`Error detecting workspace paths: ${error.message}`); // if this is a user prompt error (project path not detected) if (error.message.includes('Could not detect PWA Kit project directory')) { return { content: [{ type: 'text', text: `I need to know where your PWA Kit project is located to create the page. ${error.message}\n\nPlease provide the path to your PWA Kit project's app directory.` }] }; } return { content: [{ type: 'text', text: `Error detecting workspace configuration: ${error.message}` }] }; } }); return function (_x) { return _ref.apply(this, arguments); }; }(); } createPage(pageName, componentList, route, absolutePaths) { var _this2 = this; return _asyncToGenerator(function* () { (0, _utils.logMCPMessage)(`========== Creating page ${pageName} with components ${componentList} and route ${route}`); _this2.unfoundComponents = []; try { const messages = []; // Use the provided absolute path for pages directory const pagesDir = absolutePaths.pagesPath; pageName = (0, _utils.toPascalCase)(pageName); const pageDir = _path.default.join(pagesDir, (0, _utils.toKebabCase)(pageName)); try { yield _promises.default.access(pageDir); throw new Error(`Page directory already exists: ${pageDir}`); } catch (err) { if (err.code !== 'ENOENT') throw err; } const pageContent = yield _this2.generatePageContent(pageName, componentList, absolutePaths); const indexPath = _path.default.join(pageDir, 'index.jsx'); messages.push((0, _constants.systemPromptForFileGeneration)(indexPath, pageContent)); const routesChanges = yield _this2.updateRoutes(pageName, route, absolutePaths); messages.push((0, _constants.systemPromptForFileGeneration)(routesChanges.path, routesChanges.content)); if (_this2.unfoundComponents.length != 0) { messages.push(systemPromptForUnfoundComponents(_this2.unfoundComponents)); } messages.push(_constants.SYSTEM_PROMPT_FOR_LINT_INSTRUCTIONS); return { content: [{ type: 'text', text: (0, _constants.systemPromptForOrderedFileChanges)(messages) }] }; } catch (error) { (0, _utils.logMCPMessage)(`Error creating page: ${error.message}`); return { content: [{ type: 'text', text: `Error creating page: ${error.message}` }] }; } })(); } generatePageContent(pageName, componentList, absolutePaths) { var _this3 = this; const imports = [`import React from 'react'`, `import Seo from '@salesforce/retail-react-app/app/components/seo'`]; const sharedUIComponents = ['Box']; // Add component imports const accessPromises = componentList.map(/*#__PURE__*/function () { var _ref2 = _asyncToGenerator(function* (component) { component = (0, _utils.toPascalCase)(component); const componentName = component.charAt(0).toUpperCase() + component.slice(1); const componentDir = (0, _utils.toKebabCase)(componentName); // Use the provided absolute paths for component detection const isLocal = (0, _utils.isLocalComponent)(componentDir, absolutePaths.componentsPath); const isLocalSharedUI = (0, _utils.isLocalSharedUIComponent)(componentDir, absolutePaths.componentsPath); const isBase = (0, _utils.isBaseComponent)(componentDir, absolutePaths.nodeModulesPath); const isSharedUI = (0, _utils.isSharedUIBaseComponent)(componentDir, absolutePaths.nodeModulesPath); if (!isLocal && !isLocalSharedUI && !isBase && !isSharedUI) { _this3.unfoundComponents.push(component); } // Import getAssetUrl for displaying image source if Image component is used if (componentName === 'Image') { imports.push(`import {getAssetUrl} from '@salesforce/pwa-kit-react-sdk/ssr/universal/utils'`); } if (isLocalSharedUI || isSharedUI) { sharedUIComponents.push(componentName); return; } // If the component name is the same as the page name, add 'Component' to the component name to avoid conflict with the page name const importComponentName = componentName === pageName ? componentName + 'Component' : componentName; const importComponentPath = (0, _utils.generateComponentImportStatement)(importComponentName, componentDir, isLocal, isBase, absolutePaths, absolutePaths.hasOverridesDir); imports.push(importComponentPath); }); return function (_x2) { return _ref2.apply(this, arguments); }; }()); // Import all shared UI components in a single import statement if (sharedUIComponents.length > 0) { const importSharedUIComponents = sharedUIComponents.join(', '); imports.push(`import {${importSharedUIComponents}} from '@salesforce/retail-react-app/app/components/shared/ui'`); } return Promise.all(accessPromises).then(() => { const componentJsx = componentList.map(component => { component = (0, _utils.toPascalCase)(component); const componentName = component.charAt(0).toUpperCase() + component.slice(1); // If the component name is the same as the page name, add 'Component' to the component name const importComponentName = componentName === pageName ? componentName + 'Component' : componentName; if (componentName === 'Image') { return ` <Image src={getAssetUrl('static/img/hero.png')} alt="pwa-kit banner" style={{ width: '700px', height: 'auto' }} />`; } return ` <${importComponentName} />`; }).join('\n'); return ` ${imports.join('\n')} /** * ${pageName} component * @returns {React.JSX.Element} */ const ${pageName} = () => { return ( <Box data-testid="${pageName.toLowerCase()}-page" layerStyle="page"> <Seo title="${pageName}" description="${pageName} Page" keywords="Commerce Cloud, Retail React App, React Storefront" /> ${componentJsx} </Box> ); } export default ${pageName}; `; }); } updateRoutes(pageName, route, absolutePaths) { return _asyncToGenerator(function* () { // Use the provided absolute path to the routes.jsx file const routesPath = absolutePaths.routesPath; try { const routesContent = yield _promises.default.readFile(routesPath, 'utf8'); const importStatement = `const ${pageName} = loadable(() => import('./pages/${(0, _utils.toKebabCase)(pageName)}'), {fallback})`; // Match all loadable import statements const loadableRegex = /const\s+\w+\s*=\s*loadable\(\(\)\s*=>\s*import\(['"`].*?['"`]\)(?:,\s*\{fallback\})?\);?/g; const matches = [...routesContent.matchAll(loadableRegex)]; if (matches.length === 0) { throw new Error('No loadable import statements found.'); } const lastMatch = matches[matches.length - 1]; const insertPosition = lastMatch.index + lastMatch[0].length; // Insert the new import after the last one let updatedContent = routesContent.slice(0, insertPosition) + `\n${importStatement}` + routesContent.slice(insertPosition); const routeObject = ` {\n path: '${route}',\n component: ${pageName},\n exact: true\n },`; // Find the routes array, works for both export and non-export cases const routesArrayRegex = /(export\s+)?const\s+routes\s*=\s*\[([\s\S]*?)\]/m; const match = updatedContent.match(routesArrayRegex); if (!match) { throw new Error('No routes array declaration found.'); } // Find the start and end of the routes array const arrayStart = match.index + match[0].indexOf('[') + 1; const arrayEnd = match.index + match[0].lastIndexOf(']'); let arrayBody = updatedContent.slice(arrayStart, arrayEnd).trim(); // Remove leading/trailing commas and whitespace arrayBody = arrayBody.replace(/^,|,$/g, '').trim(); // Remove trailing '}' if present after a spread operator (e.g., ..._routes} in case of generated app) arrayBody = arrayBody.replace(/(\.\.\.[^,}\]]+)}\s*$/, '$1'); if (arrayBody) { if (!arrayBody.match(/\.\.\.[^,}\]]+\s*$/)) { if (!arrayBody.endsWith(',')) { arrayBody += ','; } } else { arrayBody = arrayBody.replace(/,\s*$/, ''); } } const newArrayBody = `\n${routeObject}\n${arrayBody ? ' ' + arrayBody : ''}\n`; // Reassemble the file updatedContent = updatedContent.slice(0, arrayStart) + newArrayBody + updatedContent.slice(arrayEnd); // return the updated file to the agent to integrate return { path: routesPath, content: updatedContent }; } catch (error) { throw new Error(`Failed to update routes: ${error.message}`); } })(); } } const createNewPageTool = new CreateNewPageTool(); var _default = exports.default = createNewPageTool;