UNPKG

@salesforce/pwa-kit-mcp

Version:

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

313 lines (296 loc) 18.2 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"); 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) \ - What is the absolute path to your node_modules directory? \ - What is the absolute path to your components directory? \ - What is the absolute path to your pages directory? \ - What is the absolute path to your routes.jsx file? \ - Is ccExtensibility.overridesDir set in your package.json? (true/false) \ Collect answers to these questions, then call the tool with the collected information as input parameters.`; const systemPromptForProductHook = `User have added the ProductView component to the new page. Please ask user: \ "To make it work, would you like to add the hook useProduct to your page?" \ If user answers yes, please make sure do do following: \ 1. add the useProduct with ALL parameters following product-detail's useProduct as example, \ 2. update ProductView tag to pass product and isProductLoading as props, \ 3. in routes.jsx, update the path for the new page with '/:productId'. \ 4. open the new page in the browser with URL: http://localhost:3000/{static-route-path}/25592300M \ If user answers no, skip above steps.`; const systemPromptForImageComponent = `User has added the Image component to the new page. Please ask the user, after they have provided the URL route: "To make it work, would you like to provide the full path of the image source to your page? Note that CORS (Cross-Origin Resource Sharing) restrictions may apply." If the user answers yes, please do the following: 1. Ask the user to provide the full path of the image source. 2. If the image is from a URL from the internet, always check in the codebase if the domain of the image source is present in the CSP img-src allow list in ssr.js or related server files. 3. When checking the allow list, also check for wildcard entries (e.g., '*.domain.com') that would cover the provided domain. If either the exact domain or a matching wildcard is present, inform the user that the image source is already allowed by the CSP and proceed. 4. If neither the domain nor a matching wildcard is present in the allow list, inform the user and **do not** update the src of the Image component. Tell the user that the CSP img-src allow list needs to be updated manually, and do not proceed further until the domain is confirmed present in the codebase. 5. **Do not** update the CSP img-src allow list in ssr.js yourself; always ask the user to update the CSP img-src allow list manually, even if the user requests it. 6. Once the user confirms they have updated the CSP img-src allow list, check again in the codebase if the domain of the image source or a matching wildcard is part of the CSP img-src allow list. If yes, then proceed. If no, tell the user that the CSP img-src allow list needs to be updated manually, or ask the user to provide another image source. 7. Only if the image from the internet is part of the CSP img-src allow list (either by exact domain or wildcard), then proceed. 8. If the image source is an https URL, then use the image source directly in the Image component. If it's a local image, update the path inside getAssetUrl and use it in the Image component. 9. Ask the user to provide the alt text for the image. 10. Ask the user to provide the width and height of the image. 11. Update the src, alt text, width, and height of the Image component in the newly created page once the user has provided them. 12. Request the user to restart the front end server. 13. Open the new page in the browser with URL: http://localhost:3000/{static-route-path} If the user answers no, tell the user that the default image located at /static/img/hero.png is being displayed. Note: If the automation cannot detect the Image component but you know it exists and works as intended, proceed with the above steps as if the component is available. Do not block or alter the workflow due to the detection issue. Clearly communicate to the user that the Image component will be used, even if it was not detected by the automation. `; 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 = 'create_sample_storefront_page'; this.description = 'Create a sample PWA storefront 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)'), nodeModulesPath: _zod.z.string().describe('The absolute path to the node_modules directory'), componentsPath: _zod.z.string().describe('The absolute path to the components directory'), pagesPath: _zod.z.string().describe('The absolute path to the pages directory'), routesPath: _zod.z.string().describe('The absolute path to the routes.jsx file'), hasOverridesDir: _zod.z.boolean().describe('Whether ccExtensibility.overridesDir is set in package.json') }; this.unfoundComponents = []; this.handler = /*#__PURE__*/function () { var _ref = _asyncToGenerator(function* (args) { (0, _utils.logMCPMessage)(`------- Calling CreateNewPageTool handler`); if (!args || !args.pageName || !args.componentList || !args.route || !args.nodeModulesPath || !args.componentsPath || !args.pagesPath || !args.routesPath || args.hasOverridesDir === undefined) { return { role: 'system', content: [{ type: 'text', text: systemPromptForCreatePage }] }; } return _this.createPage(args.pageName, args.componentList, args.route, { nodeModulesPath: args.nodeModulesPath, componentsPath: args.componentsPath, pagesPath: args.pagesPath, routesPath: args.routesPath, hasOverridesDir: args.hasOverridesDir }); }); 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 = []; yield (0, _utils.logMCPMessage)(`Creating page ${pageName} with components ${componentList} and route ${route}`); 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; } yield _promises.default.mkdir(pageDir, { recursive: true }); if (componentList.length == 0) { componentList.push(pageName); } const pageContent = yield _this2.generatePageContent(pageName, componentList, absolutePaths); (0, _utils.logMCPMessage)(`!!!!!! \n pageContent: ${pageContent} \n !!!!!`); const indexPath = _path.default.join(pageDir, 'index.jsx'); yield _promises.default.writeFile(indexPath, pageContent, 'utf8'); yield _this2.updateRoutes(pageName, route, absolutePaths); messages.push(`Created page ${pageName} at ${pageDir}`); messages.push(`Added route ${route}`); (0, _utils.logMCPMessage)(`componentList: ${componentList}`); if (componentList.includes('ProductView')) { messages.push(systemPromptForProductHook); } if (componentList.includes('Image')) { messages.push(systemPromptForImageComponent); } (0, _utils.logMCPMessage)(`Unfound components: ${_this2.unfoundComponents}`); if (_this2.unfoundComponents.length != 0) { messages.push(systemPromptForUnfoundComponents(_this2.unfoundComponents)); } (0, _utils.logMCPMessage)(messages.join('\n')); return { role: 'system', content: [{ type: 'text', text: messages.join('\n') }] }; } catch (error) { (0, _utils.logMCPMessage)(`Error creating page: ${error.message}`); return { role: 'developer', 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(() => { (0, _utils.logMCPMessage)(`?????? imports ${imports.join('\n')}`); 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 `/* * Copyright (c) ${new Date().getFullYear()}, 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 */ ${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})`; (0, _utils.logMCPMessage)(`!!!!!!!!!! importStatement: ${importStatement}`); // 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); yield _promises.default.writeFile(routesPath, updatedContent, 'utf8'); } catch (error) { throw new Error(`Failed to update routes: ${error.message}`); } })(); } } const createNewPageTool = new CreateNewPageTool(); var _default = exports.default = createNewPageTool;