|
| 1 | +/* |
| 2 | + * Copyright (c) 2025, Salesforce, Inc. |
| 3 | + * All rights reserved. |
| 4 | + * SPDX-License-Identifier: BSD-3-Clause |
| 5 | + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause |
| 6 | + */ |
| 7 | +import fs from 'fs/promises' |
| 8 | +import path from 'path' |
| 9 | +import {toKebabCase, toPascalCase, logMCPMessage} from '../utils' |
| 10 | +import {z} from 'zod' |
| 11 | + |
| 12 | +const systemPromptForCreatePage = `You are a smart assistant that can use tools when needed. \ |
| 13 | + Please ask the user to provide following information **one at a time**, in a natural and conversational way. \ |
| 14 | + Do **not** ask all the questions at once. \ |
| 15 | + Do **not** assume the answers to the questions, especially the URL route. **Always** ask the user for the URL route. \ |
| 16 | + - What is the name of the new page to create? \ |
| 17 | + - List the components to include on the page, separated by commas (e.g., Image, ProductView) \ |
| 18 | + - What is the URL route for this page? (e.g., /new-home, /my-products) \ |
| 19 | + Collect answers to these questions, then call the tool with the collected information as input parameters.` |
| 20 | + |
| 21 | +const systemPromptForProductHook = `User have added the ProductView component to the new page. Please ask user: \ |
| 22 | + "To make it work, would you like to add the hook useProduct to your page?" \ |
| 23 | + If user answers yes, please make sure do do following: \ |
| 24 | + 1. add the useProduct with ALL parameters following product-detail's useProduct as example, \ |
| 25 | + 2. update ProductView tag to pass product and isProductLoading as props, \ |
| 26 | + 3. in routes.jsx, update the path for the new page with '/:productId'. \ |
| 27 | + 4. open the new page in the browser with URL: http://localhost:3000/{static-route-path}/25592300M \ |
| 28 | + If user answers no, skip above steps.` |
| 29 | + |
| 30 | +const systemPromptForImageComponent = `User has added the Image component to the new page. Please ask the user, after they have provided the URL route: |
| 31 | + "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." |
| 32 | + |
| 33 | + If the user answers yes, please do the following: |
| 34 | + 1. Ask the user to provide the full path of the image source. |
| 35 | + 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. |
| 36 | + 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. |
| 37 | + 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. |
| 38 | + 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. |
| 39 | + 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. |
| 40 | + 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. |
| 41 | + 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. |
| 42 | + 9. Ask the user to provide the alt text for the image. |
| 43 | + 10. Ask the user to provide the width and height of the image. |
| 44 | + 11. Update the src, alt text, width, and height of the Image component in the newly created page once the user has provided them. |
| 45 | + 12. Request the user to restart the front end server. |
| 46 | + 13. Open the new page in the browser with URL: http://localhost:3000/{static-route-path} |
| 47 | + |
| 48 | + If the user answers no, tell the user that the default image located at /static/img/hero.png is being displayed. |
| 49 | + |
| 50 | + Note: |
| 51 | + 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. |
| 52 | + ` |
| 53 | + |
| 54 | +const systemPromptForUnfoundComponents = (unfoundComponents) => |
| 55 | + `The following components were not found: ${unfoundComponents.join(', ')}. \ |
| 56 | + If the component is not found, **Please** suggest changes to the newly generated page file based on the components not found.` |
| 57 | + |
| 58 | +class CreateNewPageTool { |
| 59 | + constructor() { |
| 60 | + this.name = 'create_sample_storefront_page' |
| 61 | + this.description = |
| 62 | + '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.' |
| 63 | + this.inputSchema = { |
| 64 | + pageName: z.string().describe('The name of the new page to create?'), |
| 65 | + componentList: z |
| 66 | + .array(z.string()) |
| 67 | + .describe( |
| 68 | + 'The existing components to include on the page, separated by commas (e.g., AddressDisplay, ProductView, Footer)' |
| 69 | + ), |
| 70 | + route: z |
| 71 | + .string() |
| 72 | + .describe('The URL route for this page? (e.g., /new-home, /my-product-view)') |
| 73 | + } |
| 74 | + this.unfoundComponents = [] |
| 75 | + |
| 76 | + this.handler = async (args) => { |
| 77 | + logMCPMessage(`------- Calling CreateNewPageTool handler`) |
| 78 | + if (!args || !args.pageName || !args.componentList || !args.route) { |
| 79 | + return { |
| 80 | + role: 'system', |
| 81 | + content: [{type: 'text', text: systemPromptForCreatePage}] |
| 82 | + } |
| 83 | + } |
| 84 | + return this.createPage(args.pageName, args.componentList, args.route) |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + async createPage(pageName, componentList, route) { |
| 89 | + logMCPMessage( |
| 90 | + `========== Creating page ${pageName} with layout 'flex' and components ${componentList} and route ${route}` |
| 91 | + ) |
| 92 | + this.unfoundComponents = [] |
| 93 | + await logMCPMessage( |
| 94 | + `Creating page ${pageName} with layout 'flex' and components ${componentList} and route ${route}` |
| 95 | + ) |
| 96 | + |
| 97 | + try { |
| 98 | + const messages = [] |
| 99 | + const pagesDir = path.join(process.env.PWA_STOREFRONT_APP_PATH, 'pages') |
| 100 | + pageName = toPascalCase(pageName) |
| 101 | + const pageDir = path.join(pagesDir, toKebabCase(pageName)) |
| 102 | + try { |
| 103 | + await fs.access(pageDir) |
| 104 | + throw new Error(`Page directory already exists: ${pageDir}`) |
| 105 | + } catch (err) { |
| 106 | + if (err.code !== 'ENOENT') throw err |
| 107 | + } |
| 108 | + await fs.mkdir(pageDir, {recursive: true}) |
| 109 | + if (componentList.length == 0) { |
| 110 | + componentList.push(pageName) |
| 111 | + } |
| 112 | + const pageContent = await this.generatePageContent(pageName, componentList) |
| 113 | + logMCPMessage(`!!!!!! \n pageContent: ${pageContent} \n !!!!!`) |
| 114 | + const indexPath = path.join(pageDir, 'index.jsx') |
| 115 | + await fs.writeFile(indexPath, pageContent, 'utf8') |
| 116 | + await this.updateRoutes(pageName, route) |
| 117 | + messages.push(`Created page ${pageName} at ${pageDir}`) |
| 118 | + messages.push(`Added route ${route}`) |
| 119 | + logMCPMessage(`componentList: ${componentList}`) |
| 120 | + if (componentList.includes('ProductView')) { |
| 121 | + messages.push(systemPromptForProductHook) |
| 122 | + } |
| 123 | + if (componentList.includes('Image')) { |
| 124 | + messages.push(systemPromptForImageComponent) |
| 125 | + } |
| 126 | + logMCPMessage(`Unfound components: ${this.unfoundComponents}`) |
| 127 | + if (this.unfoundComponents.length != 0) { |
| 128 | + messages.push(systemPromptForUnfoundComponents(this.unfoundComponents)) |
| 129 | + } |
| 130 | + logMCPMessage(messages.join('\n')) |
| 131 | + return { |
| 132 | + role: 'system', |
| 133 | + content: [{type: 'text', text: messages.join('\n')}] |
| 134 | + } |
| 135 | + } catch (error) { |
| 136 | + logMCPMessage(`Error creating page: ${error.message}`) |
| 137 | + return { |
| 138 | + role: 'developer', |
| 139 | + content: [{type: 'text', text: `Error creating page: ${error.message}`}] |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + generatePageContent(pageName, componentList) { |
| 145 | + const imports = [ |
| 146 | + `import React from 'react'`, |
| 147 | + `import {Box} from '@salesforce/retail-react-app/app/components/shared/ui'`, |
| 148 | + `import Seo from '@salesforce/retail-react-app/app/components/seo'` |
| 149 | + ] |
| 150 | + |
| 151 | + // Add component imports |
| 152 | + const accessPromises = componentList.map(async (component) => { |
| 153 | + component = toPascalCase(component) |
| 154 | + const componentName = component.charAt(0).toUpperCase() + component.slice(1) |
| 155 | + const componentDir = toKebabCase(componentName) |
| 156 | + try { |
| 157 | + await fs.access( |
| 158 | + path.join(process.env.PWA_STOREFRONT_APP_PATH, 'components', componentDir) |
| 159 | + ) |
| 160 | + } catch (err) { |
| 161 | + if (err.code === 'ENOENT') { |
| 162 | + this.unfoundComponents.push(component) |
| 163 | + } else { |
| 164 | + throw err |
| 165 | + } |
| 166 | + } |
| 167 | + logMCPMessage( |
| 168 | + `?????? importing ${componentName} from '@salesforce/retail-react-app/app/components/${componentDir}'` |
| 169 | + ) |
| 170 | + imports.push( |
| 171 | + `import {getAssetUrl} from '@salesforce/pwa-kit-react-sdk/ssr/universal/utils'`, |
| 172 | + `import ${componentName} from '@salesforce/retail-react-app/app/components/${componentDir}'` |
| 173 | + ) |
| 174 | + }) |
| 175 | + |
| 176 | + return Promise.all(accessPromises).then(() => { |
| 177 | + logMCPMessage(`?????? imports ${imports.join('\n')}`) |
| 178 | + |
| 179 | + const componentJsx = componentList |
| 180 | + .map((component) => { |
| 181 | + component = toPascalCase(component) |
| 182 | + const componentName = component.charAt(0).toUpperCase() + component.slice(1) |
| 183 | + if (componentName === 'Image') { |
| 184 | + return ` <Image src={getAssetUrl('static/img/hero.png')} alt="pwa-kit banner" style={{ width: '700px', height: 'auto' }} />` |
| 185 | + } |
| 186 | + return ` <${componentName} />` |
| 187 | + }) |
| 188 | + .join('\n') |
| 189 | + |
| 190 | + return `/* |
| 191 | + * Copyright (c) ${new Date().getFullYear()}, Salesforce, Inc. |
| 192 | + * All rights reserved. |
| 193 | + * SPDX-License-Identifier: BSD-3-Clause |
| 194 | + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause |
| 195 | + */ |
| 196 | +
|
| 197 | +${imports.join('\n')} |
| 198 | +
|
| 199 | +/** |
| 200 | + * ${pageName} component |
| 201 | + * @returns {React.JSX.Element} |
| 202 | + */ |
| 203 | +const ${pageName} = () => { |
| 204 | +
|
| 205 | + return ( |
| 206 | + <Box data-testid="${pageName.toLowerCase()}-page" layerStyle="page" display="flex"> |
| 207 | + <Seo |
| 208 | + title="${pageName}" |
| 209 | + description="${pageName} Page" |
| 210 | + keywords="Commerce Cloud, Retail React App, React Storefront" |
| 211 | + /> |
| 212 | +
|
| 213 | +${componentJsx} |
| 214 | + </Box> |
| 215 | + ); |
| 216 | +} |
| 217 | +
|
| 218 | +export default ${pageName}; |
| 219 | + ` |
| 220 | + }) |
| 221 | + } |
| 222 | + |
| 223 | + async updateRoutes(pageName, route) { |
| 224 | + const routesPath = path.join(process.env.PWA_STOREFRONT_APP_PATH, 'routes.jsx') |
| 225 | + try { |
| 226 | + const routesContent = await fs.readFile(routesPath, 'utf8') |
| 227 | + |
| 228 | + const importStatement = `const ${pageName} = loadable(() => import('./pages/${toKebabCase( |
| 229 | + pageName |
| 230 | + )}'), {fallback})` |
| 231 | + |
| 232 | + logMCPMessage(`!!!!!!!!!! importStatement: ${importStatement}`) |
| 233 | + |
| 234 | + // Match all loadable import statements |
| 235 | + const loadableRegex = |
| 236 | + /const\s+\w+\s*=\s*loadable\(\(\)\s*=>\s*import\(['"`].*?['"`]\)(?:,\s*\{fallback\})?\);?/g |
| 237 | + const matches = [...routesContent.matchAll(loadableRegex)] |
| 238 | + |
| 239 | + if (matches.length === 0) { |
| 240 | + throw new Error('No loadable import statements found.') |
| 241 | + } |
| 242 | + |
| 243 | + const lastMatch = matches[matches.length - 1] |
| 244 | + const insertPosition = lastMatch.index + lastMatch[0].length |
| 245 | + |
| 246 | + // Insert the new import after the last one |
| 247 | + let updatedContent = |
| 248 | + routesContent.slice(0, insertPosition) + |
| 249 | + `\n${importStatement}` + |
| 250 | + routesContent.slice(insertPosition) |
| 251 | + |
| 252 | + const routeObject = ` {\n path: '${route}',\n component: ${pageName},\n exact: true\n },` |
| 253 | + |
| 254 | + // Find the routes array, works for both export and non-export cases |
| 255 | + const routesArrayRegex = /(export\s+)?const\s+routes\s*=\s*\[([\s\S]*?)\]/m |
| 256 | + const match = updatedContent.match(routesArrayRegex) |
| 257 | + if (!match) { |
| 258 | + throw new Error('No routes array declaration found.') |
| 259 | + } |
| 260 | + |
| 261 | + // Find the start and end of the routes array |
| 262 | + const arrayStart = match.index + match[0].indexOf('[') + 1 |
| 263 | + const arrayEnd = match.index + match[0].lastIndexOf(']') |
| 264 | + let arrayBody = updatedContent.slice(arrayStart, arrayEnd).trim() |
| 265 | + |
| 266 | + // Remove leading/trailing commas and whitespace |
| 267 | + arrayBody = arrayBody.replace(/^,|,$/g, '').trim() |
| 268 | + |
| 269 | + // Remove trailing '}' if present after a spread operator (e.g., ..._routes} in case of generated app) |
| 270 | + arrayBody = arrayBody.replace(/(\.\.\.[^,}\]]+)}\s*$/, '$1') |
| 271 | + |
| 272 | + if (arrayBody) { |
| 273 | + if (!arrayBody.match(/\.\.\.[^,}\]]+\s*$/)) { |
| 274 | + if (!arrayBody.endsWith(',')) { |
| 275 | + arrayBody += ',' |
| 276 | + } |
| 277 | + } else { |
| 278 | + arrayBody = arrayBody.replace(/,\s*$/, '') |
| 279 | + } |
| 280 | + } |
| 281 | + |
| 282 | + const newArrayBody = `\n${routeObject}\n${arrayBody ? ' ' + arrayBody : ''}\n` |
| 283 | + |
| 284 | + // Reassemble the file |
| 285 | + updatedContent = |
| 286 | + updatedContent.slice(0, arrayStart) + newArrayBody + updatedContent.slice(arrayEnd) |
| 287 | + |
| 288 | + await fs.writeFile(routesPath, updatedContent, 'utf8') |
| 289 | + } catch (error) { |
| 290 | + throw new Error(`Failed to update routes: ${error.message}`) |
| 291 | + } |
| 292 | + } |
| 293 | +} |
| 294 | + |
| 295 | +const createNewPageTool = new CreateNewPageTool() |
| 296 | + |
| 297 | +export default createNewPageTool |
0 commit comments