diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index a9262c65..1226e662 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -312,7 +312,7 @@ Storefront Next development tools for building modern storefronts. | `storefront_next_figma_to_component_workflow` | Convert Figma designs to Storefront Next components | | `storefront_next_generate_component` | Generate a new Storefront Next component | | `storefront_next_map_tokens_to_theme` | Map design tokens to Storefront Next theme configuration | -| `storefront_next_design_decorator` | Apply design decorators to Storefront Next components | +| `storefront_next_page_designer_decorator` | Add Page Designer decorators to Storefront Next components | | `storefront_next_generate_page_designer_metadata` | Generate Page Designer metadata for Storefront Next components | | `scapi_schemas_list` | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | | `scapi_custom_apis_status` | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | @@ -415,7 +415,7 @@ npx mcp-inspector --cli node bin/dev.js --toolsets all --allow-non-ga-tools --me # Call a specific tool npx mcp-inspector --cli node bin/dev.js --toolsets all --allow-non-ga-tools \ --method tools/call \ - --tool-name storefront_next_design_decorator + --tool-name storefront_next_page_designer_decorator ``` #### 2. IDE Integration diff --git a/packages/b2c-dx-mcp/content/page-designer.md b/packages/b2c-dx-mcp/content/page-designer.md index 022f523a..74021bf4 100644 --- a/packages/b2c-dx-mcp/content/page-designer.md +++ b/packages/b2c-dx-mcp/content/page-designer.md @@ -35,7 +35,7 @@ To add a new content page: define a page type and ID in Commerce Cloud, then in - **Add a metadata class** with `@Component('typeId', { name, description })` and `@AttributeDefinition()` (and optionally `@AttributeDefinition({ type: 'image' })`, `type: 'url'`, etc.) for each prop you want editable in Page Designer. Use `@RegionDefinition([...])` if the component has nested regions (e.g. a grid with slots). - **Implement the React component** so it accepts those props (and strips Page Designer–only props like `component`, `page`, `componentData`, `designMetadata` before spreading to the DOM). If the component needs server data (e.g. products for a carousel), export a `loader({ componentData, context })` and optionally a `fallback` component; the registry calls the loader during `collectComponentDataPromises` and passes resolved data as the `data` prop. -- **Use the MCP tool `storefront_next_design_decorator`** to generate decorators instead of writing them by hand. Example components: `components/hero/index.tsx`, `components/content-card/index.tsx`, `components/product-carousel/index.tsx`. +- **Use the MCP tool `storefront_next_page_designer_decorator`** to generate decorators instead of writing them by hand. Example components: `components/hero/index.tsx`, `components/content-card/index.tsx`, `components/product-carousel/index.tsx`. ### After changes @@ -50,7 +50,7 @@ To add a new content page: define a page type and ID in Commerce Cloud, then in Use the **B2C DX MCP server** for Page Designer work instead of hand-writing decorators and metadata. Configure the B2C DX MCP server in your IDE (e.g. in MCP settings) so these tools are available. -### 1. `storefront_next_design_decorator` (STOREFRONTNEXT toolset) +### 1. `storefront_next_page_designer_decorator` (STOREFRONTNEXT toolset) Adds Page Designer decorators to an existing React component so it can be used in Business Manager. The tool analyzes the component, picks suitable props, infers types (e.g. `*Url`/`*Link` → url, `*Image` → image, `is*`/`show*` → boolean), and generates `@Component('typeId', { name, description })`, `@AttributeDefinition()` on a metadata class, and optionally `@RegionDefinition([...])` for nested regions. It skips complex or UI-only props (e.g. className, style, callbacks). @@ -71,7 +71,7 @@ Packages the cartridge, uploads it to Commerce Cloud via WebDAV, and unpacks it ### Typical workflow -1. **`storefront_next_design_decorator`** — Add decorators to the component (use autoMode for a quick first pass). +1. **`storefront_next_page_designer_decorator`** — Add decorators to the component (use autoMode for a quick first pass). 2. **`storefront_next_generate_page_designer_metadata`** — Generate metadata JSON so the component and regions appear in Page Designer. 3. **`cartridge_deploy`** — Deploy to Commerce Cloud so merchants can use the component in Business Manager. @@ -81,6 +81,6 @@ Packages the cartridge, uploads it to Commerce Cloud via WebDAV, and unpacks it 2. **Use registry for components**: Register all Page Designer components with proper `typeId` 3. **Handle design mode**: Adapt UI when `pageDesignerMode` is `'EDIT'` or `'PREVIEW'` 4. **Rebuild after registry changes**: Static registry is generated at build time -5. **Use MCP tools**: Leverage `storefront_next_design_decorator` and `storefront_next_generate_page_designer_metadata` for faster development +5. **Use MCP tools**: Leverage `storefront_next_page_designer_decorator` and `storefront_next_generate_page_designer_metadata` for faster development **Reference:** See README.md for complete Page Designer documentation and MCP tool setup. diff --git a/packages/b2c-dx-mcp/package.json b/packages/b2c-dx-mcp/package.json index 5f471540..b2e63088 100644 --- a/packages/b2c-dx-mcp/package.json +++ b/packages/b2c-dx-mcp/package.json @@ -96,6 +96,8 @@ "@modelcontextprotocol/sdk": "1.26.0", "@oclif/core": "catalog:", "@salesforce/b2c-tooling-sdk": "workspace:*", + "glob": "catalog:", + "ts-morph": "^27.0.0", "yaml": "2.8.1", "zod": "3.25.76" }, diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/README.md b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/README.md new file mode 100644 index 00000000..c5646246 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/README.md @@ -0,0 +1,261 @@ +# Page Designer Decorator Tool + +Tool for adding Page Designer decorators to React components using native TypeScript template literals. + +## 🎯 Overview + +This tool analyzes React components and generates Page Designer decorators (`@Component`, `@AttributeDefinition`, `@RegionDefinition`) to make components available in Page Designer for Storefront Next. + +## ✨ Key Features + +- **Name-Based Lookup**: Find components by name (e.g., "ProductCard") without knowing paths +- **Auto-Discovery**: Automatically searches common component directories +- **Type-Safe**: Full TypeScript type inference for all contexts +- **Fast**: Direct function execution, no file I/O or compilation overhead +- **Flexible Input**: Supports component names or file paths +- **Two Modes**: Auto mode for quick setup, Interactive mode for fine-tuned control + +## 📁 File Structure + +``` +page-designer-decorator/ +├── analyzer.ts # Component parsing and analysis +├── rules.ts # Rule loader and exports +├── index.ts # Main tool implementation +├── rules/ +│ ├── 1-mode-selection.ts # Entry point +│ ├── 2a-auto-mode.ts # Auto mode workflow +│ ├── 2b-0-interactive-overview.ts # Interactive workflow overview +│ ├── 2b-1-interactive-analyze.ts # Step 1: Analysis +│ ├── 2b-2-interactive-select-props.ts # Step 2: Selection +│ ├── 2b-3-interactive-configure-attrs.ts # Step 3: Configuration +│ ├── 2b-4-interactive-configure-regions.ts # Step 4: Regions +│ └── 2b-5-interactive-confirm-generation.ts # Step 5: Generation +└── templates/ + └── decorator-generator.ts # Decorator code generation +``` + +## 🚀 Usage + +### Basic Usage (Name-Based - Recommended) + +```bash +# By component name (automatically finds the file) +storefront_next_page_designer_decorator({ + component: "ProductCard", + autoMode: true +}) + +# Interactive mode +storefront_next_page_designer_decorator({ + component: "Hero", + conversationContext: { step: "analyze" } +}) + +# With custom search paths (for unusual locations) +storefront_next_page_designer_decorator({ + component: "ProductCard", + searchPaths: ["packages/retail/src", "app/features"], + autoMode: true +}) +``` + +### Path-Based Usage + +```bash +# If you prefer to specify the exact path +storefront_next_page_designer_decorator({ + component: "src/components/ProductCard.tsx", + autoMode: true +}) +``` + +### Workflow + +1. **Component Discovery**: Provide name (e.g., "ProductCard") or path +2. **Mode Selection**: Choose Auto or Interactive mode +3. **Analysis** (Interactive only): Review component props +4. **Selection** (Interactive only): Select which props to expose +5. **Configuration** (Interactive only): Configure types and defaults +6. **Regions** (Interactive only): Configure nested content areas +7. **Generation**: Get decorator code + +### Component Discovery + +The tool automatically searches for components in these locations (in order): + +1. `src/components/**` (PascalCase and kebab-case) +2. `app/components/**` +3. `components/**` +4. `src/**` (broader search) +5. Custom paths (if provided via `searchPaths`) + +**Working Directory:** +Component discovery uses the working directory resolved from `--working-directory` flag or `SFCC_WORKING_DIRECTORY` environment variable (via Services). This ensures searches start from the correct project directory, especially when MCP clients spawn servers from the home directory. + +**Examples:** + +- `"ProductCard"` → finds `src/components/product-tile/ProductCard.tsx` +- `"Hero"` → finds `src/components/hero/Hero.tsx` or `app/components/hero.tsx` +- `"product-card"` → finds `src/components/product-card.tsx` or `product-card/index.tsx` + +**Tips:** + +- Use component name for portability +- Use path for unusual locations +- Add `searchPaths` for monorepos or non-standard structures +- Ensure `--working-directory` flag or `SFCC_WORKING_DIRECTORY` env var is set correctly + +## 🏗️ Architecture + +### Rule Rendering + +Rules are pure TypeScript functions that return strings: + +```typescript +${context.hasEditableProps + ? context.editableProps.map(prop => + `- \`${prop.name}\` (${prop.type})` + ).join('\n') + : '' +} +``` + +### Type Safety + +Every rule has a strongly-typed context interface: + +```typescript +export interface AnalyzeStepContext { + componentName: string; + file: string; + hasEditableProps: boolean; + editableProps: PropInfo[]; + // ... more fields +} + +export function renderAnalyzeStep(context: AnalyzeStepContext): string { + // TypeScript checks all variable access at compile time +} +``` + +### Template Generation + +Code generation uses pure functions: + +```typescript +export function generateDecoratorCode(context: MetadataContext): string { + const imports = generateImports(context); + const decorator = generateComponentDecorator(context); + const attributes = generateAttributes(context); + + return `${imports}${decorator}\nexport class ${context.metadataClassName} {\n${attributes}\n}`; +} +``` + +## 📦 Build Process + +All rules and templates are compiled into the JavaScript output: + +```json +{ + "scripts": { + "build": "tsc" + } +} +``` + +## 🎯 When to Use This Tool + +Use this tool when: + +- ✅ You need to add Page Designer support to React components +- ✅ You want automatic component discovery by name +- ✅ You prefer type-safe decorator generation +- ✅ You need both quick auto-mode and detailed interactive workflows + +## 🔧 Development + +### Adding a New Rule + +1. Create a new file in `rules/`: + +```typescript +// rules/my-new-rule.ts +export interface MyRuleContext { + message: string; +} + +export function renderMyRule(context: MyRuleContext): string { + return `# My Rule\n\n${context.message}`; +} +``` + +2. Export it from `rules.ts`: + +```typescript +import {renderMyRule, type MyRuleContext} from './rules/my-new-rule.js'; + +export const pageDesignerDecoratorRules = { + // ... existing rules + getMyRule(context: MyRuleContext): string { + return renderMyRule(context); + }, +}; +``` + +3. Use it in `index.ts`: + +```typescript +const instructions = pageDesignerDecoratorRules.getMyRule({ + message: 'Hello World', +}); +``` + +### Modifying Code Generation + +Edit `templates/decorator-generator.ts` directly. Changes require recompilation. + +## 📊 Performance + +The tool uses direct function execution with no file I/O or compilation overhead. Typical tool invocations complete in under 1ms. + +## ✅ Testing + +### Automated Tests + +```bash +pnpm build +pnpm test +``` + +Comprehensive test suite covers all workflow modes, component discovery, and error handling. + +### Running Tests + +Run the comprehensive Mocha test suite: + +```bash +cd packages/b2c-dx-mcp +pnpm run test:agent -- test/tools/page-designer-decorator/index.test.ts +``` + +The test suite covers: +- Component discovery (name-based, kebab-case, nested, path-based, custom paths, name collisions) +- Auto mode (basic, type inference, complex props exclusion, UI-only props exclusion, edge cases) +- Interactive mode (all steps: analyze, select_props, configure_attrs, configure_regions, confirm_generation) +- Error handling (invalid input, invalid step name, missing parameters) +- Edge cases (no props, only complex props, optional props, union types, already decorated components) +- Working directory resolution (from --working-directory flag or SFCC_WORKING_DIRECTORY env var via Services) + +See [`test/tools/page-designer-decorator/README.md`](../../../test/tools/page-designer-decorator/README.md) for detailed testing instructions. + +## 🎓 Learning Resources + +- [Template Literals (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) +- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/intro.html) +- [MCP Tools Documentation](https://modelcontextprotocol.io/docs) + +## 📝 License + +Apache-2.0 - Copyright (c) 2025, Salesforce, Inc. diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/analyzer.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/analyzer.ts new file mode 100644 index 00000000..41d4d1c9 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/analyzer.ts @@ -0,0 +1,638 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {existsSync, readFileSync} from 'node:fs'; +import path from 'node:path'; +import {globSync} from 'glob'; +import {Project, InterfaceDeclaration, PropertySignature} from 'ts-morph'; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +/** + * Component analysis result + */ +export interface ComponentInfo { + componentName: string; + interfaceName: null | string; + hasDecorators: boolean; + props: PropInfo[]; + exportType: 'default' | 'named'; + filePath: string; +} + +/** + * Property information extracted from component interface + */ +export interface PropInfo { + name: string; + type: string; + optional: boolean; + isComplex: boolean; // Can't be used directly in Page Designer + isUIOnly: boolean; // Styling/layout props not suitable for PD +} + +/** + * Type suggestion for attribute configuration + */ +export interface TypeSuggestion { + type: string; + reason: string; + priority: 'high' | 'low' | 'medium'; +} + +// ============================================================================ +// TYPE INFERENCE +// ============================================================================ + +/** + * Type mapping from TypeScript to SFCC Page Designer attribute types + */ +const TYPE_MAPPING: Record = { + String: 'string', + string: 'string', + Number: 'integer', + number: 'integer', + Boolean: 'boolean', + boolean: 'boolean', + Date: 'string', + URL: 'url', + CMSRecord: 'cms_record', +}; + +/** + * Valid SFCC Page Designer attribute types + */ +export const VALID_ATTRIBUTE_TYPES = [ + 'string', + 'text', + 'markup', + 'integer', + 'boolean', + 'product', + 'category', + 'file', + 'page', + 'image', + 'url', + 'enum', + 'custom', + 'cms_record', +] as const; + +/** + * Infer Page Designer attribute type from TypeScript type + */ +export function inferPageDesignerType(tsType: string): string { + if (TYPE_MAPPING[tsType]) { + return TYPE_MAPPING[tsType]; + } + + if (tsType.includes('|')) { + const firstType = tsType.split('|')[0].trim(); + return inferPageDesignerType(firstType); + } + + if (tsType.includes('[]') || tsType.includes('Array<')) { + return 'string'; + } + + return 'string'; +} + +/** + * Check if TypeScript type can be auto-inferred + */ +export function isAutoInferredType(tsType: string): boolean { + return Boolean(TYPE_MAPPING[tsType]); +} + +/** + * Check if type is too complex for Page Designer + */ +export function isComplexType(tsType: string): boolean { + return ( + tsType.includes('{') || + tsType.includes('<') || + tsType.includes('.') || + tsType.includes('=>') || + tsType.includes('React.') || + tsType.startsWith('(') + ); +} + +/** + * Check if property is UI-only + */ +export function isUIOnlyProp(propName: string): boolean { + const uiPatterns = [ + 'classname', + 'style', + 'theme', + 'variant', + 'size', + 'color', + 'loading', + 'disabled', + 'readonly', + 'onclick', + 'onchange', + 'onsubmit', + 'children', + 'key', + 'ref', + ]; + const nameLower = propName.toLowerCase(); + return uiPatterns.some((pattern) => nameLower.includes(pattern)); +} + +/** + * Generate Page Designer attribute type suggestions for a component prop + * + * **Inference Strategy:** + * Uses naming patterns and TypeScript types to suggest appropriate Page Designer types. + * This reduces manual configuration by auto-detecting common patterns. + * + * **Page Designer Types:** + * - `string`: Default text input + * - `url`: URL/link inputs (validates URL format) + * - `image`: Image asset picker + * - `html`: Rich text editor + * - `markup`: HTML/markdown editor + * - `enum`: Dropdown with predefined values + * - `boolean`: Checkbox + * - `number`: Numeric input + * - `product`: Product picker (SFCC-specific) + * - `category`: Category picker (SFCC-specific) + * + * **Heuristics (by priority):** + * 1. **High Priority**: Strong patterns (url, image, product) + * 2. **Medium Priority**: Contextual patterns (html, markup) + * 3. **Low Priority**: Weak signals (description → markup) + * + * Multiple suggestions allow developers to choose the best fit. + * + * @param propName - Property name from component interface + * @param tsType - TypeScript type string + * @returns Array of type suggestions with reasoning and priority + * + * @example + * // URL detection: + * generateTypeSuggestions('imageUrl', 'string') + * // => [{ type: 'url', reason: '...', priority: 'high' }] + * + * @example + * // Image detection: + * generateTypeSuggestions('heroImage', 'string') + * // => [{ type: 'image', reason: '...', priority: 'high' }] + * + * @example + * // Multiple suggestions: + * generateTypeSuggestions('description', 'string') + * // => [ + * // { type: 'markup', reason: '...', priority: 'low' }, + * // { type: 'html', reason: '...', priority: 'medium' } + * // ] + * + * @example + * // Product reference: + * generateTypeSuggestions('product', 'string') + * // => [{ type: 'product', reason: '...', priority: 'high' }] + * + * @public + */ +export function generateTypeSuggestions(propName: string, tsType: string): TypeSuggestion[] { + const suggestions: TypeSuggestion[] = []; + const nameLower = propName.toLowerCase(); + + // URL patterns + if (nameLower.includes('url') || nameLower.includes('link') || nameLower.includes('href')) { + suggestions.push({ + type: 'url', + reason: 'Property name suggests URL/link', + priority: 'high', + }); + } + + // Image patterns + if ( + nameLower.includes('image') || + nameLower.includes('img') || + nameLower.includes('picture') || + nameLower.includes('background') + ) { + suggestions.push({ + type: 'image', + reason: 'Property name suggests image asset', + priority: 'high', + }); + } + + // Rich text patterns + if ( + nameLower.includes('html') || + nameLower.includes('richtext') || + nameLower.includes('content') || + nameLower.includes('body') + ) { + suggestions.push({ + type: 'markup', + reason: 'Property name suggests rich content', + priority: 'medium', + }); + } + + // Multi-line text patterns + if (nameLower.includes('description') || nameLower.includes('bio') || nameLower.includes('message')) { + suggestions.push({ + type: 'text', + reason: 'Property name suggests multi-line text', + priority: 'medium', + }); + } + + // Array patterns + if (tsType.includes('[]') || tsType.includes('Array<')) { + suggestions.push({ + type: 'enum', + reason: 'Array types work best as enums for selection in Page Designer', + priority: 'high', + }); + } + + // Product/Category references + if (nameLower.includes('product') && !nameLower.includes('products')) { + suggestions.push({ + type: 'product', + reason: 'Property name suggests product reference', + priority: 'high', + }); + } + + if (nameLower.includes('category')) { + suggestions.push({ + type: 'category', + reason: 'Property name suggests category reference', + priority: 'high', + }); + } + + return suggestions; +} + +// ============================================================================ +// COMPONENT FILE PARSING +// ============================================================================ + +/** + * Extract component name from file content + */ +function extractComponentName(content: string): string { + const defaultFunctionMatch = content.match(/export\s+default\s+function\s+(\w+)/); + if (defaultFunctionMatch) { + return defaultFunctionMatch[1]; + } + + const namedFunctionMatch = content.match(/export\s+function\s+(\w+)/); + if (namedFunctionMatch) { + return namedFunctionMatch[1]; + } + + const namedConstMatch = content.match(/export\s+const\s+(\w+)\s*=/); + if (namedConstMatch) { + return namedConstMatch[1]; + } + + return 'Component'; +} + +/** + * Detect export type + */ +function detectExportType(content: string): 'default' | 'named' { + return content.includes('export default') ? 'default' : 'named'; +} + +/** + * Parse component file and extract structure + */ +function parseComponentFile(filePath: string): ComponentInfo { + const content = readFileSync(filePath, 'utf8'); + + const hasDecorators = content.includes('@Component') || content.includes('@PageType'); + + if (hasDecorators) { + return { + componentName: extractComponentName(content), + interfaceName: null, + hasDecorators: true, + props: [], + exportType: detectExportType(content), + filePath, + }; + } + + const project = new Project({ + useInMemoryFileSystem: true, + skipAddingFilesFromTsConfig: true, + }); + + const sourceFile = project.createSourceFile(filePath, content); + const interfaces = sourceFile.getInterfaces(); + const propsInterface = interfaces.find((i: InterfaceDeclaration) => i.getName().includes('Props')); + + if (!propsInterface) { + return { + componentName: extractComponentName(content), + interfaceName: null, + hasDecorators: false, + props: [], + exportType: detectExportType(content), + filePath, + }; + } + + const props: PropInfo[] = propsInterface.getProperties().map((prop: PropertySignature) => { + const name = prop.getName(); + const type = prop.getType().getText(); + const optional = prop.hasQuestionToken(); + + return { + name, + type, + optional, + isComplex: isComplexType(type), + isUIOnly: isUIOnlyProp(name), + }; + }); + + return { + componentName: extractComponentName(content), + interfaceName: propsInterface.getName(), + hasDecorators: false, + props, + exportType: detectExportType(content), + filePath, + }; +} + +// ============================================================================ +// COMPONENT ANALYZER +// ============================================================================ + +/** + * Component analyzer for Page Designer decorator generation + */ +class ComponentAnalyzer { + private cache: Map = new Map(); + + analyzeComponent(filePath: string): ComponentInfo { + const cached = this.cache.get(filePath); + if (cached) { + return cached; + } + + const analysis = parseComponentFile(filePath); + this.cache.set(filePath, analysis); + + return analysis; + } + + clearCache() { + this.cache.clear(); + } +} + +export const componentAnalyzer = new ComponentAnalyzer(); + +// ============================================================================ +// COMPONENT RESOLUTION (Name-Based Lookup) +// ============================================================================ + +/** + * Convert PascalCase or camelCase to kebab-case + * + * Used for finding components with different naming conventions. + * React components are typically PascalCase, but file names may be kebab-case. + * + * @param str - String to convert (e.g., "ProductCard", "myComponent") + * @returns Kebab-case string (e.g., "product-card", "my-component") + * + * @example + * toKebabCase('ProductCard') // => 'product-card' + * toKebabCase('MyButtonComponent') // => 'my-button-component' + * toKebabCase('heroSection') // => 'hero-section' + * + * @internal + */ +function toKebabCase(str: string): string { + return str + .replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2') + .replaceAll(/([A-Z])([A-Z][a-z])/g, '$1-$2') + .toLowerCase(); +} + +/** + * Search for component file by name using smart discovery patterns + * + * **Search Strategy (in priority order):** + * 1. Common component directories with exact name (PascalCase) + * 2. Kebab-case variants of the name + * 3. Index file patterns (for directory-based components) + * 4. Broader search in src/ + * 5. Custom search paths (if provided) + * + * **Why this order:** + * - Most projects follow conventions (src/components/) + * - PascalCase is React standard, checked first + * - Kebab-case is common for file names + * - Index files are common for complex components + * - Fallback to broader search if not in standard locations + * + * **Disambiguation:** + * If multiple files match, prefers the shortest path (closest to root). + * This typically selects the main component over similar named test/story files. + * + * @param componentName - Component name without extension (e.g., "ProductCard", "Hero") + * @param workspaceRoot - Absolute path to workspace root + * @param customPaths - Additional directories to search (e.g., ["packages/retail/src"]) + * @returns Absolute file path or null if not found + * + * @example + * // Finds: src/components/product-tile/ProductCard.tsx + * findComponentByName('ProductCard', '/workspace', undefined) + * + * @example + * // Finds: src/components/hero.tsx or src/components/hero/index.tsx + * findComponentByName('hero', '/workspace', undefined) + * + * @example + * // Searches in custom paths first + * findComponentByName('ProductCard', '/workspace', ['packages/retail/src']) + * + * @internal + */ +function findComponentByName(componentName: string, workspaceRoot: string, customPaths?: string[]): null | string { + // Normalize component name (remove file extensions) + const cleanName = componentName.replace(/\.(tsx?|jsx?)$/, ''); + const kebabName = toKebabCase(cleanName); + + // Search patterns (in order of priority) + const searchPatterns = [ + // Common component directories (PascalCase) + `src/components/**/${cleanName}.tsx`, + `src/components/**/${cleanName}.ts`, + `app/components/**/${cleanName}.tsx`, + `components/**/${cleanName}.tsx`, + + // Kebab-case variants + `src/components/**/${kebabName}.tsx`, + `app/components/**/${kebabName}.tsx`, + `components/**/${kebabName}.tsx`, + + // Index file patterns + `src/components/**/${kebabName}/index.tsx`, + `app/components/**/${kebabName}/index.tsx`, + + // Anywhere in src/ (broader search) + `src/**/${cleanName}.tsx`, + `src/**/${cleanName}.ts`, + `src/**/${kebabName}.tsx`, + + // Custom search paths (if provided) + ...(customPaths?.flatMap((path) => [ + `${path}/**/${cleanName}.tsx`, + `${path}/**/${cleanName}.ts`, + `${path}/**/${kebabName}.tsx`, + `${path}/**/${kebabName}/index.tsx`, + ]) || []), + ]; + + // Search with glob + for (const pattern of searchPatterns) { + try { + const matches = globSync(pattern, { + cwd: workspaceRoot, + absolute: true, + ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/out/**'], + }); + + if (matches.length > 0) { + // If multiple matches, prefer shortest path (closest to root) + const sorted = matches.sort((a, b) => a.length - b.length); + return sorted[0]; + } + } catch { + // Ignore glob errors and try next pattern + continue; + } + } + + return null; +} + +/** + * Resolve component input (name or path) to absolute file path + * + * **This is the main entry point for component discovery.** + * + * Supports two input modes: + * 1. **Name-based** (recommended): Just provide the component name + * 2. **Path-based** (backward compatible): Provide relative path from workspace + * + * **Name-based detection:** + * Input is treated as a name if it: + * - Does NOT contain path separators (/ or \) + * - Does NOT have a file extension (.tsx, .ts, etc.) + * + * **Path-based detection:** + * Input is treated as a path if it: + * - Contains / or \ + * - Has a file extension + * + * @param input - Component name or relative path + * @param workspaceRoot - Absolute path to workspace root + * @param searchPaths - Additional directories to search (only used for name-based) + * @returns Absolute file path to component + * @throws {Error} If component cannot be found, with detailed search information + * + * @example + * // Name-based (finds automatically): + * resolveComponent('ProductCard', '/workspace') + * // => '/workspace/src/components/product-tile/ProductCard.tsx' + * + * @example + * // Path-based (backward compatible): + * resolveComponent('src/components/ProductCard.tsx', '/workspace') + * // => '/workspace/src/components/ProductCard.tsx' + * + * @example + * // With custom search paths (for monorepos): + * resolveComponent('Hero', '/workspace', ['packages/retail/src', 'packages/shared']) + * // => '/workspace/packages/retail/src/components/Hero.tsx' + * + * @example + * // Error handling: + * try { + * resolveComponent('NonExistent', '/workspace') + * } catch (err) { + * // Error includes: + * // - List of searched locations + * // - Tried name variations + * // - Helpful tips for resolution + * } + * + * @public + */ +export function resolveComponent(input: string, workspaceRoot: string, searchPaths?: string[]): string { + // Check if input looks like a path (has / or \ or file extension) + const looksLikePath = input.includes('/') || input.includes('\\') || input.match(/\.(tsx?|jsx?|mjs|cjs|js)$/); + + if (looksLikePath) { + // Treat as path (backward compatible) + const fullPath = path.join(workspaceRoot, input); + if (existsSync(fullPath)) { + return fullPath; + } + throw new Error( + `Component file not found at path: ${input}\n\n` + + `Full path checked: ${fullPath}\n\n` + + `Tips:\n` + + ` 1. Use component name instead (e.g., "ProductCard") for automatic discovery\n` + + ` 2. If components are in a different repo, set --working-directory flag or SFCC_WORKING_DIRECTORY env var`, + ); + } + + // Treat as component name - search for it + const found = findComponentByName(input, workspaceRoot, searchPaths); + + if (!found) { + const searchLocations = [ + 'src/components/**', + 'app/components/**', + 'components/**', + 'src/**', + ...(searchPaths || []), + ]; + + throw new Error( + `Component "${input}" not found.\n\n` + + `Searched in:\n${searchLocations.map((loc) => ` - ${loc}`).join('\n')}\n\n` + + `Tried variations:\n` + + ` - ${input}.tsx\n` + + ` - ${toKebabCase(input)}.tsx\n` + + ` - ${toKebabCase(input)}/index.tsx\n\n` + + `Tips:\n` + + ` 1. Provide full path: component: "src/components/ProductCard.tsx"\n` + + ` 2. Add custom search: searchPaths: ["packages/retail/src"]\n` + + ` 3. Check component name spelling and casing\n` + + ` 4. If components are in a different repo, set --working-directory flag or SFCC_WORKING_DIRECTORY env var`, + ); + } + + return found; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/index.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/index.ts new file mode 100644 index 00000000..e94ba047 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/index.ts @@ -0,0 +1,706 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z, type ZodRawShape} from 'zod'; +import {componentAnalyzer, generateTypeSuggestions, resolveComponent, type TypeSuggestion} from './analyzer.js'; +import {generateDecoratorCode, type AttributeContext, type MetadataContext} from './templates/decorator-generator.js'; +import {pageDesignerDecoratorRules} from './rules.js'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; + +// ============================================================================ +// SCHEMA DEFINITION +// ============================================================================ + +export const pageDesignerDecoratorSchema = z + .object({ + component: z + .string() + .describe( + 'Component name (e.g., "ProductCard", "Hero") or file path (e.g., "src/components/ProductCard.tsx"). ' + + 'When a name is provided, the tool automatically searches common component directories. ' + + 'For backward compatibility, file paths are also supported.', + ), + + searchPaths: z + .array(z.string()) + .optional() + .describe( + 'Additional directories to search for components (e.g., ["packages/retail/src", "app/features"]). ' + + 'Only used when component is specified by name (not path).', + ), + + autoMode: z + .boolean() + .optional() + .describe( + 'Auto-generate all configurations with sensible defaults (skip interactive workflow). When enabled, automatically selects suitable props, infers types, and generates decorators without user confirmation.', + ), + + componentId: z.string().optional().describe('Override component ID (default: auto-generated from component name)'), + + conversationContext: z + .object({ + step: z + .enum(['analyze', 'select_props', 'configure_attrs', 'configure_regions', 'confirm_generation']) + .optional() + .describe('Current step in the conversation workflow'), + + componentInfo: z + .record(z.string(), z.any()) + .optional() + .describe('Cached component analysis from previous step'), + + selectedProps: z + .array(z.string()) + .optional() + .describe('Props from component interface selected to expose in Page Designer'), + + newAttributes: z + .array( + z.object({ + name: z.string(), + description: z.string().optional(), + required: z.boolean().optional(), + }), + ) + .optional() + .describe('New attributes to add (not in existing props)'), + + attributeConfig: z + .record( + z.string(), + z.object({ + type: z.string().optional(), + name: z.string().optional(), + defaultValue: z.any().optional(), + values: z.array(z.string()).optional(), + }), + ) + .optional() + .describe('Configuration for each attribute (explicit types, names, etc.)'), + + componentMetadata: z + .object({ + id: z.string(), + name: z.string(), + description: z.string(), + group: z.string().optional(), + }) + .optional() + .describe('Component decorator configuration'), + + regionConfig: z + .object({ + enabled: z.boolean().describe('Whether to include @RegionDefinition decorator'), + regions: z + .array( + z.object({ + id: z.string().describe('Region identifier (e.g., "main", "sidebar")'), + name: z.string().describe('Display name for the region'), + description: z.string().optional().describe('Description of the region purpose'), + maxComponents: z.number().optional().describe('Maximum number of components allowed in region'), + componentTypeInclusions: z + .array(z.string()) + .optional() + .describe('Allowed component types (whitelist)'), + componentTypeExclusions: z + .array(z.string()) + .optional() + .describe('Disallowed component types (blacklist)'), + }), + ) + .optional() + .describe('Array of region definitions'), + }) + .optional() + .describe('Region configuration for nested content areas'), + }) + .optional() + .describe('Conversation state for multi-turn interaction'), + }) + .strict(); + +export type PageDesignerDecoratorInput = z.infer; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Convert component name to kebab-case for use as component ID + * + * Page Designer component IDs should be lowercase with hyphens. + * + * @param name - PascalCase or camelCase name + * @returns kebab-case identifier + * + * @example + * toKebabCase('ProductCard') // => 'product-card' + * toKebabCase('TwoColumnLayout') // => 'two-column-layout' + * + * @internal + */ +function toKebabCase(name: string): string { + return name + .replaceAll(/([a-z])([A-Z])/g, '$1-$2') + .replaceAll(/[\s_]+/g, '-') + .toLowerCase(); +} + +/** + * Convert camelCase prop name to human-readable display name + * + * Used for attribute names shown to merchants in Page Designer UI. + * + * @param fieldName - camelCase field name + * @returns Human-readable name with proper capitalization + * + * @example + * toHumanReadableName('imageUrl') // => 'Image Url' + * toHumanReadableName('ctaButtonText') // => 'Cta Button Text' + * + * @internal + */ +function toHumanReadableName(fieldName: string): string { + return fieldName + .replaceAll(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()) + .trim(); +} + +// ============================================================================ +// WORKFLOW STEP HANDLERS +// ============================================================================ + +/** + * Handle Interactive Mode - Step 1: Analyze + * + * Parses the component file and provides analysis to the LLM: + * - Component name and structure + * - All props with types + * - Categorization (editable, complex, UI-only) + * - Suggested component ID and name + * + * **LLM should then:** + * - Present findings to user + * - Ask which props to expose in Page Designer + * - Collect component metadata (ID, name, description, group) + * - Call next step with selectedProps and componentMetadata + * + * @internal + */ +function handleAnalyzeStep(args: PageDesignerDecoratorInput, workspaceRoot: string) { + const fullPath = resolveComponent(args.component, workspaceRoot, args.searchPaths); + const componentInfo = componentAnalyzer.analyzeComponent(fullPath); + + const editableProps = componentInfo.props.filter((p) => !p.isComplex && !p.isUIOnly); + const complexProps = componentInfo.props.filter((p) => p.isComplex); + const uiProps = componentInfo.props.filter((p) => p.isUIOnly && !p.isComplex); + + const suggestedComponentId = args.componentId || toKebabCase(componentInfo.componentName); + const suggestedComponentName = toHumanReadableName(componentInfo.componentName); + + const instructions = pageDesignerDecoratorRules.getAnalyzeInstructions({ + componentName: componentInfo.componentName, + file: args.component, + hasDecorators: componentInfo.hasDecorators, + interfaceName: componentInfo.interfaceName || 'None found', + totalProps: componentInfo.props.length, + exportType: componentInfo.exportType, + hasEditableProps: editableProps.length > 0, + editableProps, + hasComplexProps: complexProps.length > 0, + complexProps, + hasUIProps: uiProps.length > 0, + uiProps, + suggestedComponentId, + suggestedComponentName, + }); + + return { + content: [ + { + type: 'text' as const, + text: instructions, + }, + ], + }; +} + +function handleSelectPropsStep(args: PageDesignerDecoratorInput, _workspaceRoot: string) { + const selectedProps = args.conversationContext?.selectedProps || []; + const newAttributes = args.conversationContext?.newAttributes || []; + const componentMetadata = args.conversationContext?.componentMetadata; + + if (!componentMetadata) { + return { + content: [ + { + type: 'text' as const, + text: '⚠️ Missing component metadata. Please provide component ID, name, description, and group from the analyze step.', + }, + ], + isError: true, + }; + } + + const confirmation = pageDesignerDecoratorRules.getSelectPropsConfirmation({ + componentMetadata: { + id: componentMetadata.id, + name: componentMetadata.name, + description: componentMetadata.description, + group: componentMetadata.group || 'odyssey_base', + }, + selectedProps, + newAttributes, + selectedPropsCount: selectedProps.length, + newAttributesCount: newAttributes.length, + totalAttributeCount: selectedProps.length + newAttributes.length, + hasSelectedProps: selectedProps.length > 0, + hasNewAttributes: newAttributes.length > 0, + }); + + return { + content: [ + { + type: 'text' as const, + text: confirmation, + }, + ], + }; +} + +function handleConfigureAttrsStep(args: PageDesignerDecoratorInput, workspaceRoot: string) { + const selectedProps = args.conversationContext?.selectedProps || []; + const newAttributes = args.conversationContext?.newAttributes || []; + + const fullPath = resolveComponent(args.component, workspaceRoot, args.searchPaths); + const componentInfo = componentAnalyzer.analyzeComponent(fullPath); + + const attributeAnalysis: Array<{ + name: string; + source: 'existing' | 'new'; + tsType: string; + autoInferred: boolean; + suggestions: TypeSuggestion[]; + }> = []; + + for (const propName of selectedProps) { + const prop = componentInfo.props.find((p) => p.name === propName); + if (!prop) continue; + + const suggestions = generateTypeSuggestions(propName, prop.type); + + attributeAnalysis.push({ + name: propName, + source: 'existing', + tsType: prop.type, + autoInferred: suggestions.length === 0, + suggestions, + }); + } + + for (const attr of newAttributes) { + const suggestions = generateTypeSuggestions(attr.name, 'string'); + + attributeAnalysis.push({ + name: attr.name, + source: 'new', + tsType: 'string', + autoInferred: suggestions.length === 0, + suggestions, + }); + } + + const autoInferredAttrs = attributeAnalysis.filter((a) => a.autoInferred); + const needsConfigAttrs = attributeAnalysis.filter((a) => !a.autoInferred); + + const instructions = pageDesignerDecoratorRules.getConfigureAttrsInstructions({ + totalAttributes: attributeAnalysis.length, + autoInferredCount: autoInferredAttrs.length, + needsConfigCount: needsConfigAttrs.length, + hasAutoInferred: autoInferredAttrs.length > 0, + autoInferredAttrs: autoInferredAttrs.map((a) => ({name: a.name, tsType: a.tsType})), + hasNeedsConfig: needsConfigAttrs.length > 0, + needsConfigAttrs: needsConfigAttrs.map((attr) => ({ + name: attr.name, + tsType: attr.tsType, + source: attr.source === 'existing' ? 'Existing prop' : 'New attribute', + hasSuggestions: attr.suggestions.length > 0, + suggestions: attr.suggestions, + suggestedTypes: attr.suggestions.map((s) => s.type).join(', ') || 'string', + humanReadableName: toHumanReadableName(attr.name), + hasEnumSuggestion: attr.suggestions.some((s) => s.type === 'enum'), + })), + }); + + return { + content: [ + { + type: 'text' as const, + text: instructions, + }, + ], + }; +} + +function handleConfigureRegionsStep(args: PageDesignerDecoratorInput, workspaceRoot: string) { + const fullPath = resolveComponent(args.component, workspaceRoot, args.searchPaths); + const componentInfo = componentAnalyzer.analyzeComponent(fullPath); + + const instructions = pageDesignerDecoratorRules.getConfigureRegionsInstructions({ + componentName: componentInfo.componentName, + }); + + return { + content: [ + { + type: 'text' as const, + text: instructions, + }, + ], + }; +} + +function handleConfirmGenerationStep(args: PageDesignerDecoratorInput, workspaceRoot: string) { + const { + componentMetadata, + selectedProps = [], + newAttributes = [], + attributeConfig = {}, + } = args.conversationContext || {}; + + if (!componentMetadata) { + return { + content: [ + { + type: 'text' as const, + text: 'Error: Missing component metadata. Please start from the beginning.', + }, + ], + isError: true, + }; + } + + const fullPath = resolveComponent(args.component, workspaceRoot, args.searchPaths); + const componentInfo = componentAnalyzer.analyzeComponent(fullPath); + + const attributes: AttributeContext[] = []; + + for (const propName of selectedProps) { + const prop = componentInfo.props.find((p) => p.name === propName); + if (!prop) continue; + + const config = attributeConfig[propName]; + const hasConfig = config && Object.keys(config).length > 0; + + attributes.push({ + name: propName, + tsType: prop.type, + optional: prop.optional, + hasConfig, + config, + }); + } + + for (const attr of newAttributes) { + const config = attributeConfig[attr.name]; + const hasConfig = config && Object.keys(config).length > 0; + + attributes.push({ + name: attr.name, + tsType: 'string', + optional: !attr.required, + hasConfig, + config, + }); + } + + const regionConfig = args.conversationContext?.regionConfig; + const hasRegions = regionConfig?.enabled && regionConfig.regions && regionConfig.regions.length > 0; + + const context: MetadataContext = { + needsImports: true, + componentId: componentMetadata.id, + componentName: componentMetadata.name, + componentDescription: componentMetadata.description, + componentGroup: componentMetadata.group || 'odyssey_base', + metadataClassName: `${componentInfo.componentName}Metadata`, + hasAttributes: attributes.length > 0, + hasRegions: hasRegions || false, + hasLoader: false, + regions: hasRegions ? regionConfig.regions || [] : [], + attributes, + }; + + const decoratorCode = generateDecoratorCode(context); + + const userResponse = pageDesignerDecoratorRules.getConfirmGenerationInstructions({ + decoratorCode, + componentName: componentInfo.componentName, + componentId: componentMetadata.id, + componentGroup: componentMetadata.group || 'odyssey_base', + file: args.component, + attributeCount: attributes.length, + hasRegions: hasRegions || false, + regionCount: hasRegions && regionConfig.regions ? regionConfig.regions.length : 0, + }); + + return { + content: [ + { + type: 'text' as const, + text: userResponse, + }, + ], + }; +} + +/** + * Handle Auto Mode - Single-step decorator generation + * + * **Fully automated workflow:** + * 1. Analyzes component + * 2. Auto-selects suitable props (excludes complex and UI-only) + * 3. Auto-infers Page Designer types from naming patterns + * 4. Generates decorator code immediately + * 5. NO user interaction required + * + * **Selection criteria:** + * - ✅ Simple types (string, number, boolean) + * - ❌ Complex types (objects, functions, React nodes) + * - ❌ UI-only props (className, style, onClick, etc.) + * + * **Auto-configuration:** + * - High-confidence patterns get explicit types (url, image, enum) + * - Others use auto-inferred types + * - Human-readable names auto-generated + * - No regions configured (interactive mode for advanced features) + * + * **Use cases:** + * - Quick setup for standard components + * - Batch processing multiple components + * - Getting started quickly + * + * @internal + */ +function handleAutoMode(args: PageDesignerDecoratorInput, workspaceRoot: string) { + const fullPath = resolveComponent(args.component, workspaceRoot, args.searchPaths); + const componentInfo = componentAnalyzer.analyzeComponent(fullPath); + + if (componentInfo.hasDecorators) { + return { + content: [ + { + type: 'text' as const, + text: `# ⚠️ Component Already Decorated\n\nThe component \`${componentInfo.componentName}\` already has Page Designer decorators.\n\nWould you like to modify the existing decorators instead?`, + }, + ], + }; + } + + const selectedProps = componentInfo.props.filter((p) => !p.isComplex && !p.isUIOnly).map((p) => p.name); + + const attributeConfig: Record = {}; + const attributes: AttributeContext[] = []; + + for (const propName of selectedProps) { + const prop = componentInfo.props.find((p) => p.name === propName); + if (!prop) continue; + + const suggestions = generateTypeSuggestions(propName, prop.type); + const config: {name?: string; type?: string; values?: string[]; defaultValue?: unknown} = { + name: toHumanReadableName(propName), + }; + + const highPrioritySuggestion = suggestions.find((s) => s.priority === 'high'); + if (highPrioritySuggestion) { + config.type = highPrioritySuggestion.type; + + if (highPrioritySuggestion.type === 'enum') { + if (propName.toLowerCase().includes('size')) { + config.values = ['sm', 'default', 'lg']; + config.defaultValue = 'default'; + } else if (propName.toLowerCase().includes('variant')) { + config.values = ['default', 'primary', 'secondary']; + config.defaultValue = 'default'; + } + } + + if (highPrioritySuggestion.type === 'boolean') { + config.defaultValue = false; + } + } + + if (Object.keys(config).length > 1) { + attributeConfig[propName] = config; + } + + attributes.push({ + name: propName, + tsType: prop.type, + optional: prop.optional, + hasConfig: Object.keys(config).length > 1, + config: Object.keys(config).length > 1 ? config : undefined, + }); + } + + const componentId = args.componentId || toKebabCase(componentInfo.componentName); + const componentName = toHumanReadableName(componentInfo.componentName); + const componentDescription = `${componentName} component for Page Designer`; + + const context: MetadataContext = { + needsImports: true, + componentId, + componentName, + componentDescription, + componentGroup: 'odyssey_base', + metadataClassName: `${componentInfo.componentName}Metadata`, + hasAttributes: attributes.length > 0, + hasRegions: false, + hasLoader: false, + regions: [], + attributes, + }; + + const decoratorCode = generateDecoratorCode(context); + + const response = pageDesignerDecoratorRules.getAutoModeInstructions({ + componentName: componentInfo.componentName, + file: args.component, + componentId, + selectedPropCount: selectedProps.length, + autoConfigCount: Object.keys(attributeConfig).length, + autoInferredCount: selectedProps.length - Object.keys(attributeConfig).length, + hasNoSuitableProps: selectedProps.length === 0, + selectedProps: selectedProps.length > 0 ? selectedProps.map((p) => `\`${p}\``).join(', ') : 'None', + decoratorCode, + componentGroup: 'odyssey_base', + }); + + return { + content: [ + { + type: 'text' as const, + text: response, + }, + ], + }; +} + +// ============================================================================ +// TOOL EXPORT +// ============================================================================ + +/** + * Creates the Page Designer decorator tool for Storefront Next. + * + * @param loadServices - Function that loads configuration and returns Services instance + * @returns The configured MCP tool + */ +export function createPageDesignerDecoratorTool(loadServices: () => Services): McpTool { + return { + name: 'storefront_next_page_designer_decorator', + + description: + 'Adds Page Designer decorators (@Component, @AttributeDefinition, @RegionDefinition) to React components. ' + + 'Two modes: autoMode=true for quick setup with defaults, or interactive mode via conversationContext.step. ' + + 'Component discovery uses workingDirectory from flags/env. ' + + 'Auto mode: selects suitable props, infers types, generates code immediately. ' + + 'Interactive mode: multi-step workflow (analyze → select_props → configure_attrs → configure_regions → confirm_generation).', + + inputSchema: pageDesignerDecoratorSchema.shape as ZodRawShape, + toolsets: ['STOREFRONTNEXT'], + isGA: false, + + async handler(args: Record) { + try { + // Validate and parse input + const validatedArgs = pageDesignerDecoratorSchema.parse(args) as PageDesignerDecoratorInput; + // Use workingDirectory from services to ensure we search in the correct project directory + // This prevents searches in the home folder when MCP clients spawn servers from ~ + const services = loadServices(); + const workspaceRoot = services.getWorkingDirectory(); + + if (validatedArgs.autoMode === undefined && !validatedArgs.conversationContext) { + const fullPath = resolveComponent(validatedArgs.component, workspaceRoot, validatedArgs.searchPaths); + const componentInfo = componentAnalyzer.analyzeComponent(fullPath); + + const instructions = pageDesignerDecoratorRules.getModeSelectionInstructions({ + componentName: componentInfo.componentName, + file: validatedArgs.component, + }); + + return { + content: [ + { + type: 'text' as const, + text: instructions, + }, + ], + }; + } + + if (validatedArgs.autoMode) { + return handleAutoMode(validatedArgs, workspaceRoot); + } + + const step = validatedArgs.conversationContext?.step || 'analyze'; + + switch (step) { + case 'analyze': { + return handleAnalyzeStep(validatedArgs, workspaceRoot); + } + + case 'configure_attrs': { + return handleConfigureAttrsStep(validatedArgs, workspaceRoot); + } + + case 'configure_regions': { + return handleConfigureRegionsStep(validatedArgs, workspaceRoot); + } + + case 'confirm_generation': { + return handleConfirmGenerationStep(validatedArgs, workspaceRoot); + } + + case 'select_props': { + return handleSelectPropsStep(validatedArgs, workspaceRoot); + } + + default: { + const unknownStep: string = step; + throw new Error(`Unknown step: ${unknownStep}`); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // Check if it's a Zod validation error + if (error instanceof Error && error.name === 'ZodError') { + return { + content: [ + { + type: 'text' as const, + text: `# Error: Invalid Input\n\n${errorMessage}\n\nPlease check your input parameters and try again.`, + }, + ], + isError: true, + }; + } + return { + content: [ + { + type: 'text' as const, + text: `# Error Adding Page Designer Support\n\n${errorMessage}`, + }, + ], + isError: true, + }; + } + }, + }; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules.ts new file mode 100644 index 00000000..6ced5cdb --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Import all rule renderers +import {renderModeSelection, type ModeSelectionContext} from './rules/1-mode-selection.js'; +import {renderInteractiveOverview} from './rules/2b-0-interactive-overview.js'; +import {renderAnalyzeStep, type AnalyzeStepContext} from './rules/2b-1-interactive-analyze.js'; +import {renderSelectPropsConfirmation, type SelectPropsContext} from './rules/2b-2-interactive-select-props.js'; +import {renderConfigureAttrs, type ConfigureAttrsContext} from './rules/2b-3-interactive-configure-attrs.js'; +import {renderConfigureRegions, type ConfigureRegionsContext} from './rules/2b-4-interactive-configure-regions.js'; +import {renderConfirmGeneration, type ConfirmGenerationContext} from './rules/2b-5-interactive-confirm-generation.js'; +import {renderAutoMode, type AutoModeContext} from './rules/2a-auto-mode.js'; + +/** + * Page Designer decorator rules - type-safe, zero dependencies + */ +export const pageDesignerDecoratorRules = { + /** + * Renders the mode selection prompt + */ + getModeSelectionInstructions(context: ModeSelectionContext): string { + return renderModeSelection(context); + }, + + /** + * Renders the interactive mode workflow overview + */ + getInteractiveOverview(): string { + return renderInteractiveOverview(); + }, + + /** + * Renders Interactive Analyze step instructions + */ + getAnalyzeInstructions(context: AnalyzeStepContext): string { + const workflow = this.getInteractiveOverview(); + const stepContent = renderAnalyzeStep(context); + return `${workflow}\n\n${stepContent}`; + }, + + /** + * Renders Interactive Select Props step confirmation + */ + getSelectPropsConfirmation(context: SelectPropsContext): string { + return renderSelectPropsConfirmation(context); + }, + + /** + * Renders Interactive Configure Attributes step instructions + */ + getConfigureAttrsInstructions(context: ConfigureAttrsContext): string { + return renderConfigureAttrs(context); + }, + + /** + * Renders Interactive Configure Regions step instructions + */ + getConfigureRegionsInstructions(context: ConfigureRegionsContext): string { + return renderConfigureRegions(context); + }, + + /** + * Renders Interactive Confirm Generation step (final code presentation) + */ + getConfirmGenerationInstructions(context: ConfirmGenerationContext): string { + return renderConfirmGeneration(context); + }, + + /** + * Renders Auto Mode instructions + */ + getAutoModeInstructions(context: AutoModeContext): string { + return renderAutoMode(context); + }, +}; + +// Re-export types for convenience +export type {ModeSelectionContext} from './rules/1-mode-selection.js'; +export type {AutoModeContext} from './rules/2a-auto-mode.js'; +export type {AnalyzeStepContext} from './rules/2b-1-interactive-analyze.js'; +export type {SelectPropsContext} from './rules/2b-2-interactive-select-props.js'; +export type {ConfigureAttrsContext} from './rules/2b-3-interactive-configure-attrs.js'; +export type {ConfigureRegionsContext} from './rules/2b-4-interactive-configure-regions.js'; +export type {ConfirmGenerationContext} from './rules/2b-5-interactive-confirm-generation.js'; diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/1-mode-selection.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/1-mode-selection.ts new file mode 100644 index 00000000..3f458ce8 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/1-mode-selection.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Mode selection rule - Entry point for Page Designer decorator tool + */ +export interface ModeSelectionContext { + componentName: string; + file: string; +} + +export function renderModeSelection(context: ModeSelectionContext): string { + return `# 🎯 Choose Page Designer Setup Mode + +I need to know which mode you'd like to use for adding Page Designer support to **\`${context.componentName}\`**. + +## Available Modes + +### 🤖 Auto Mode (Quick & Automatic) +- **Best for**: Quick setup, standard components, batch processing +- **What happens**: + - Automatically analyzes the component + - Auto-selects suitable props (excludes complex types) + - Auto-infers types based on naming patterns + - Generates decorators immediately with sensible defaults + - **No confirmation needed** - code generated instantly +- **Time**: ~1 step +- **Control**: Low (uses smart defaults) + +### 👤 Interactive Mode (Step-by-Step) +- **Best for**: Complex components, custom requirements, learning the process +- **What happens**: + - Multi-step workflow with your input at each stage + - Review and approve prop selections + - Configure attribute types, names, and defaults + - Configure regions for nested content (optional) + - **Requires confirmation** before generating code +- **Time**: ~4-5 steps +- **Control**: High (you decide everything) + +## ⚡ How to Proceed + +**⚠️ IMPORTANT: WAIT for the user to choose a mode. DO NOT proceed automatically.** + +Please ask the user: **"Which mode would you like to use: Auto Mode or Interactive Mode?"** + +Once the user responds: + +**For Auto Mode**, call the tool again with: +\`\`\`json +{ + "file": "${context.file}", + "autoMode": true +} +\`\`\` + +**For Interactive Mode**, call the tool again with: +\`\`\`json +{ + "file": "${context.file}", + "conversationContext": { + "step": "analyze" + } +} +\`\`\` + +--- + +💡 **Tip**: If unsure, try **Auto Mode** first. You can always modify the generated decorators later or rerun in Interactive Mode for more control.`; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2a-auto-mode.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2a-auto-mode.ts new file mode 100644 index 00000000..75962971 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2a-auto-mode.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface AutoModeContext { + componentName: string; + file: string; + componentId: string; + selectedPropCount: number; + autoConfigCount: number; + autoInferredCount: number; + hasNoSuitableProps: boolean; + selectedProps: string; + decoratorCode: string; + componentGroup: string; +} + +export function renderAutoMode(context: AutoModeContext): string { + return `# Auto Mode - Page Designer Decorator Generation + +## LLM INSTRUCTIONS + +### Auto Mode Behavior + +**Single-Step Execution:** +- NO user interaction required +- NO questions to ask +- Analyze component automatically +- Auto-select all suitable props +- Auto-infer types based on naming patterns +- Generate code immediately + +**Auto-Selection Criteria:** +- ✅ Include: Simple props (string, number, boolean) +- ❌ Exclude: Complex types (objects, arrays, functions) +- ❌ Exclude: UI-only props (className, style, etc.) + +**Auto-Inference Patterns:** +- \`*Url\`, \`*Link\` → \`url\` type +- \`*Image\`, \`*Icon\` → \`image\` type +- \`is*\`, \`has*\`, \`enable*\`, \`show*\` → \`boolean\` type (default: false) +- \`*Size\` → \`enum\` type (values: ['default', 'primary', 'secondary']) +- \`*Variant\` → \`enum\` type (values: ['default', 'primary', 'secondary']) + +**Regions:** +- NOT configured in auto mode +- User must use interactive mode for regions + +### Component Analysis Summary + +**Component Name**: ${context.componentName} +**File**: ${context.file} +**Component ID**: ${context.componentId} +**Selected Props**: ${context.selectedPropCount} +**Auto-configured**: ${context.autoConfigCount} +**Auto-inferred**: ${context.autoInferredCount} + +${ + context.hasNoSuitableProps + ? `⚠️ **No suitable props found**. The component has only complex or UI-only props. +Consider adding new attributes manually or using interactive mode.` + : '' +} + +--- + +# USER-FACING RESPONSE + +# ✅ Page Designer Decorators Generated (Auto Mode) + +## Auto-Configuration Summary + +- **Component**: \`${context.componentName}\` +- **Component ID**: \`${context.componentId}\` +- **File**: \`${context.file}\` +- **Selected Props**: ${context.selectedProps} +- **Auto-configured**: ${context.autoConfigCount} +- **Auto-inferred**: ${context.autoInferredCount} + +${ + context.hasNoSuitableProps + ? `⚠️ **No suitable props found**. The component has only complex or UI-only props. +Consider adding new attributes manually or using interactive mode.` + : '' +} + +## Generated Code + +Add this metadata class to your component file: + +\`\`\`typescript +${context.decoratorCode} +\`\`\` + +## Next Steps + +1. **Add the code** to \`${context.file}\` (after imports, before component) +2. **Update component props** to make them optional and add type unions as needed +3. **Generate metadata**: Run \`sfnext generate-cartridge --project-directory .\` +4. **Deploy cartridge**: Run \`sfnext deploy-cartridge --project-directory .\` +5. **Verify in Business Manager**: Check Components > ${context.componentGroup} > ${context.componentName}`; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-0-interactive-overview.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-0-interactive-overview.ts new file mode 100644 index 00000000..e3b949fc --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-0-interactive-overview.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Interactive mode workflow overview + */ +export function renderInteractiveOverview(): string { + return `# ⚠️ MANDATORY: Adding Page Designer Support + +## 🚨 CRITICAL: Multi-Step Workflow + +**YOU MUST FOLLOW THIS WORKFLOW:** + +1. **analyze**: Present component analysis and ask configuration questions + - Component identity (ID, name, description, group) + - Which existing props to expose + - Whether to add new attributes + +2. **select_props**: Confirm user's selections + - Show what was selected + - Confirm component metadata + - Prepare for type configuration + +3. **configure_attrs**: Configure attribute types + - Show auto-inferred types + - Ask for explicit type configuration where needed + - Collect defaults and enum values + +4. **configure_regions**: Configure regions (optional) + - Ask if component needs nested content areas + - Configure region definitions if needed + +5. **confirm_generation**: Generate final decorator code + - Render decorators with all configurations + - Show code to user + +**VIOLATION OF THIS WORKFLOW IS A CRITICAL ERROR.** + +## Workflow Enforcement + +- Each step must complete before proceeding to the next +- User must confirm or provide input at each step +- Do not make assumptions about user preferences +- Do not skip steps, even if the answer seems obvious + +## Next Step Instructions + +After presenting analysis, you MUST: +1. Wait for user's answers to ALL questions +2. Call tool again with step: "select_props" and user's responses in conversationContext +3. NEVER proceed to code generation without completing all steps`; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-1-interactive-analyze.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-1-interactive-analyze.ts new file mode 100644 index 00000000..4752206d --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-1-interactive-analyze.ts @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface PropInfo { + name: string; + type: string; + optional: boolean; +} + +export interface AnalyzeStepContext { + componentName: string; + file: string; + hasDecorators: boolean; + interfaceName?: string; + totalProps: number; + exportType: string; + hasEditableProps: boolean; + editableProps: PropInfo[]; + hasComplexProps: boolean; + complexProps: PropInfo[]; + hasUIProps: boolean; + uiProps: PropInfo[]; + suggestedComponentId: string; + suggestedComponentName: string; +} + +export function renderAnalyzeStep(context: AnalyzeStepContext): string { + return `# Step 1: Component Analysis + +## LLM INSTRUCTIONS + +### Component Analysis Results + +**Component Name**: ${context.componentName} +**File**: ${context.file} +**Has Decorators**: ${context.hasDecorators ? 'Yes (STOP - already decorated)' : 'No (proceed)'} +**Props Interface**: ${context.interfaceName || 'None found'} +**Total Props**: ${context.totalProps} + +${ + context.hasDecorators + ? `⚠️ **CRITICAL**: Component already has Page Designer decorators. +**ACTION**: Stop here and inform user. Do not proceed with generation.` + : '' +} + +### Next Actions (LLM) + +1. Present the analysis to the user +2. Ask all configuration questions (component identity, props selection, new properties) +3. Wait for user's complete response +4. THEN call tool again with step: "select_props" with collected answers + +--- + +# USER-FACING RESPONSE + +${ + context.hasDecorators + ? `# Analysis: ${context.componentName} + +✅ **This component already has Page Designer support.** + +The component has existing decorators (@Component, @AttributeDefinition, etc.). + +Would you like to modify the existing decorators instead?` + : `# Analysis: ${context.componentName} + +## Current State + +- **Component**: \`${context.componentName}\` +- **File**: \`${context.file}\` +- **Props Interface**: \`${context.interfaceName || 'None found'}\` +- **Export Type**: ${context.exportType} + +## Existing Properties Analysis + +${ + context.hasEditableProps + ? `### ✅ Suitable for Page Designer: + +${context.editableProps.map((prop) => `- \`${prop.name}\` (${prop.type})${prop.optional ? ' - optional' : ''}`).join('\n')}` + : '### ⚠️ No suitable properties found' +} + +${ + context.hasComplexProps + ? `### ⚠️ Complex (needs simplification): + +${context.complexProps.map((prop) => `- \`${prop.name}\` (${prop.type}) - Too complex for Page Designer`).join('\n')} + +These complex types cannot be used directly. Consider creating simpler alternatives.` + : '' +} + +${ + context.hasUIProps + ? `### 🎨 UI Props (typically not exposed): + +${context.uiProps.map((prop) => `- \`${prop.name}\` (${prop.type})`).join('\n')} + +These are styling/layout props, usually not exposed to Page Designer.` + : '' +} + +## Configuration Questions + +### 1️⃣ Component Identity + +I suggest: +- **ID**: \`${context.suggestedComponentId}\` +- **Name**: "${context.suggestedComponentName}" +- **Description**: *[Please provide a description]* +- **Group**: \`odyssey_base\` (default) or specify custom group + +✏️ Are these acceptable, or would you like to change them? + +### 2️⃣ Existing Properties + +${ + context.hasEditableProps + ? `**Which properties should be editable in Page Designer?** + +${context.editableProps.map((prop) => `- [ ] \`${prop.name}\` - ${prop.type}`).join('\n')}` + : '⚠️ No existing properties are suitable.' +} + +### 3️⃣ New Properties + +**Should I add any new properties** that don't exist in the component interface? + +Examples: +- Button text, labels, or headings +- Toggle flags (show/hide elements) +- Configuration options + +--- + +**Please answer these questions to proceed.**` +}`; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-2-interactive-select-props.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-2-interactive-select-props.ts new file mode 100644 index 00000000..7f7f4e98 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-2-interactive-select-props.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface NewAttribute { + name: string; + description?: string; + required?: boolean; +} + +export interface SelectPropsContext { + componentMetadata: { + id: string; + name: string; + description: string; + group?: string; + }; + selectedProps: string[]; + newAttributes: NewAttribute[]; + selectedPropsCount: number; + newAttributesCount: number; + totalAttributeCount: number; + hasSelectedProps: boolean; + hasNewAttributes: boolean; +} + +export function renderSelectPropsConfirmation(context: SelectPropsContext): string { + return `# Step 2: Selection Confirmation + +## LLM INSTRUCTIONS + +### Purpose +Present a clear confirmation of the user's selections from Step 1 (analyze). + +### Context Provided +- Component identity (id, name, description, group) +- Array of selected existing prop names +- Array of new attributes to add +- Counts and flags + +### Next Actions +After showing confirmation, instruct user to confirm proceeding to type configuration. + +--- + +# USER-FACING RESPONSE + +# ✅ Selection Confirmed + +## Component Configuration + +- **ID**: \`${context.componentMetadata.id}\` +- **Name**: "${context.componentMetadata.name}" +- **Description**: "${context.componentMetadata.description}" +- **Group**: \`${context.componentMetadata.group || 'odyssey_base'}\` + +${ + context.hasSelectedProps + ? `## 📋 Selected Existing Props (${context.selectedPropsCount}) + +${context.selectedProps.map((prop) => `- \`${prop}\``).join('\n')}` + : `## 📋 Selected Existing Props + +None selected.` +} + +${ + context.hasNewAttributes + ? `## ➕ New Attributes to Add (${context.newAttributesCount}) + +${context.newAttributes.map((attr) => `- \`${attr.name}\`${attr.description ? ` - ${attr.description}` : ''}${attr.required ? ' (required)' : ''}`).join('\n')}` + : `## ➕ New Attributes to Add + +None requested.` +} + +--- + +## 🎯 Next Step: Attribute Configuration + +Now I'll analyze the types for these ${context.totalAttributeCount} attribute(s) and help you configure them for Page Designer. + +**Please confirm**: Ready to proceed with type configuration? (Say "yes" or "proceed")`; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-3-interactive-configure-attrs.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-3-interactive-configure-attrs.ts new file mode 100644 index 00000000..03a06d70 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-3-interactive-configure-attrs.ts @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface TypeSuggestion { + type: string; + priority: string; + reason: string; +} + +export interface ConfigureAttrsContext { + totalAttributes: number; + autoInferredCount: number; + needsConfigCount: number; + hasAutoInferred: boolean; + autoInferredAttrs: Array<{name: string; tsType: string}>; + hasNeedsConfig: boolean; + needsConfigAttrs: Array<{ + name: string; + tsType: string; + source: string; + hasSuggestions: boolean; + suggestions: TypeSuggestion[]; + suggestedTypes: string; + humanReadableName: string; + hasEnumSuggestion: boolean; + }>; +} + +export function renderConfigureAttrs(context: ConfigureAttrsContext): string { + return `# Step 2: Attribute Configuration + +## LLM INSTRUCTIONS + +### Analysis Complete + +Total attributes to configure: ${context.totalAttributes} +Auto-inferred: ${context.autoInferredCount} +Need configuration: ${context.needsConfigCount} + +### Next Steps for LLM + +**WAIT for user to:** +1. Provide explicit type overrides (if desired) +2. Provide custom names/descriptions (if desired) +3. Provide enum values (if applicable) +4. Or confirm "use defaults" + +**THEN** call tool again with step: "configure_regions" + +--- + +# USER-FACING RESPONSE + +# Attribute Configuration + +${ + context.hasAutoInferred + ? `## ✅ Auto-Configured Attributes + +These attributes will use auto-inferred types (no explicit configuration needed): + +${context.autoInferredAttrs.map((attr) => `- **${attr.name}** (${attr.tsType}) → Auto-inferred as Page Designer type`).join('\n')}` + : '' +} + +${ + context.hasNeedsConfig + ? `## ⚙️ Attributes Needing Configuration + +${context.needsConfigAttrs + .map( + (attr, index) => `### ${index + 1}. \`${attr.name}\` + +- **TypeScript Type**: \`${attr.tsType}\` +- **Source**: ${attr.source} + +${ + attr.hasSuggestions + ? `**Recommendations**: + +${attr.suggestions.map((s) => `- **${s.type}** (${s.priority} priority): ${s.reason}`).join('\n')}` + : '' +} + +**Questions**: +- What Page Designer type should this be? (${attr.suggestedTypes}) +- Custom display name? (default: "${attr.humanReadableName}") +- Default value? +${attr.hasEnumSuggestion ? '- Enum values? (e.g., ["option1", "option2", "option3"])' : ''}`, + ) + .join('\n\n')}` + : '' +} + +--- + +**Please provide configuration for attributes that need it, or say "use defaults" to proceed.**`; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-4-interactive-configure-regions.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-4-interactive-configure-regions.ts new file mode 100644 index 00000000..6e702581 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-4-interactive-configure-regions.ts @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface ConfigureRegionsContext { + componentName: string; +} + +export function renderConfigureRegions(context: ConfigureRegionsContext): string { + return `# Step 3: Region Configuration + +## LLM INSTRUCTIONS + +### 🚨 CRITICAL: Ask User About Regions + +**YOU MUST:** +1. Ask user if component needs regions for nested content +2. If YES, ask for region configurations: + - Region ID (e.g., "main", "sidebar", "footer") + - Region name (display name) + - Description (optional) + - Max components (optional) + - Component type filters (optional) +3. Wait for user response +4. THEN call tool again with step: "confirm_generation" and regionConfig filled + +### Region Context + +Regions allow business users to nest other components inside this component. +Examples: Hero with content slots, Layout containers, Section wrappers + +--- + +# USER-FACING RESPONSE + +# Step 3: Region Configuration + +**Component**: ${context.componentName} + +## About Regions + +Regions define areas where business users can insert other components in Page Designer. +Use regions for: +- Layout containers (e.g., grid, flex layouts) +- Content areas with multiple components +- Sections that need nested content + +**Does this component need regions for nested content?** + +Examples: +- ✅ **YES** for: Layout, Container, Section, Grid +- ❌ **NO** for: Button, Image, Text, ProductCarousel + +--- + +**Please answer:** +1. Does this component need regions? (yes/no) +2. If yes, provide region configuration(s) + +**Region Configuration Example:** +\`\`\` +- id: "main" + name: "Main Content" + description: "Primary content area" + maxComponents: 10 +\`\`\``; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-5-interactive-confirm-generation.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-5-interactive-confirm-generation.ts new file mode 100644 index 00000000..331af273 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/rules/2b-5-interactive-confirm-generation.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface ConfirmGenerationContext { + decoratorCode: string; + componentName: string; + componentId: string; + componentGroup: string; + file: string; + attributeCount: number; + hasRegions: boolean; + regionCount?: number; +} + +export function renderConfirmGeneration(context: ConfirmGenerationContext): string { + return `# Step 5: Code Generation + +## LLM INSTRUCTIONS + +### Purpose +Present the generated Page Designer decorator code and next steps. + +### Context Provided +- Complete generated metadata class with decorators +- Component name, ID, group +- File path, attribute count, region info + +### Next Actions +1. Show the generated code +2. Provide clear next steps for deployment +3. Optionally offer to add code to the file + +--- + +# USER-FACING RESPONSE + +# ✅ Page Designer Decorators Generated + +## Generated Code + +Add this metadata class to \`${context.file}\`: + +\`\`\`typescript +${context.decoratorCode} +\`\`\` + +## Summary + +- **Component**: ${context.componentName} +- **Component ID**: \`${context.componentId}\` +- **Group**: \`${context.componentGroup}\` +- **Attributes**: ${context.attributeCount} +${context.hasRegions ? `- **Regions**: ${context.regionCount} configured` : ''} + +## Next Steps + +### 1. Add the Code + +Add the generated metadata class to \`${context.file}\`: +- Place it **after imports** +- Place it **before the component definition** + +### 2. Update Component Props (if needed) + +Make decorated props optional in your component interface: + +\`\`\`typescript +interface ${context.componentName}Props { + title?: string; // Add ? if attribute is not required + // ... other props +} +\`\`\` + +### 3. Generate Cartridge Metadata + +\`\`\`bash +cd packages/template-retail-rsc-app +pnpm sfnext generate-cartridge --project-directory . +\`\`\` + +This creates JSON files in \`cartridges/app_storefrontnext_base/cartridge/experience/components/\`. + +### 4. Deploy Cartridge + +\`\`\`bash +pnpm sfnext deploy-cartridge --project-directory . +\`\`\` + +### 5. Verify in Business Manager + +1. Log into Business Manager +2. Navigate to: **Merchant Tools > Site > Page Designer** +3. Find your component: **Components > ${context.componentGroup} > ${context.componentName}** +4. Verify all attributes appear correctly +5. Test editing a page with your component + +--- + +**Would you like me to add this code to your component file?**`; +} diff --git a/packages/b2c-dx-mcp/src/tools/page-designer-decorator/templates/decorator-generator.ts b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/templates/decorator-generator.ts new file mode 100644 index 00000000..ac3b6384 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/page-designer-decorator/templates/decorator-generator.ts @@ -0,0 +1,413 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface AttributeContext { + name: string; + tsType: string; + optional: boolean; + hasConfig: boolean; + config?: { + id?: string; + type?: string; + name?: string; + description?: string; + defaultValue?: unknown; + required?: boolean; + values?: string[]; + }; +} + +export interface RegionContext { + id: string; + name: string; + description?: string; + maxComponents?: number; + componentTypeInclusions?: string[]; + componentTypeExclusions?: string[]; +} + +export interface MetadataContext { + needsImports: boolean; + componentId: string; + componentName: string; + componentDescription: string; + componentGroup?: string; + metadataClassName: string; + hasAttributes: boolean; + hasRegions: boolean; + hasLoader: boolean; + regions: RegionContext[]; + attributes: AttributeContext[]; +} + +/** + * Generate a simple attribute decorator with auto-inferred type + * + * **Simple attributes** use default Page Designer behavior: + * - Type is inferred from TypeScript type + * - No custom configuration needed + * - Minimal decorator syntax + * + * **When to use:** + * - Basic string, number, or boolean props + * - No special validation or defaults needed + * - Standard field naming is acceptable + * + * @param attr - Attribute context + * @returns TypeScript code string for the attribute + * + * @example + * // Input: + * { name: 'title', tsType: 'string', optional: false, hasConfig: false } + * + * // Output: + * `@AttributeDefinition() + * title!: string;` + * + * @internal + */ +function generateSimpleAttribute(attr: AttributeContext): string { + return ` @AttributeDefinition() + ${attr.name}${attr.optional ? '?' : '!'}: ${attr.tsType};`; +} + +/** + * Generate a configured attribute decorator with explicit settings + * + * **Configured attributes** specify custom Page Designer behavior: + * - Explicit `type` (url, image, enum, etc.) + * - Custom `name` for display in Page Designer UI + * - `description` for merchant guidance + * - `defaultValue` for new instances + * - `required` flag for validation + * - `values` array for enum types + * + * **When to use:** + * - URL, image, or rich text fields (need specific editors) + * - Enum fields with predefined options + * - Fields with default values + * - Fields with merchant-friendly names + * + * @param attr - Attribute context with configuration + * @returns TypeScript code string for the configured attribute + * + * @example + * // Input (URL field): + * { + * name: 'ctaUrl', + * tsType: 'string', + * optional: false, + * hasConfig: true, + * config: { + * type: 'url', + * name: 'CTA Button URL', + * description: 'Destination URL for the call-to-action button' + * } + * } + * + * // Output: + * `@AttributeDefinition({ + * type: 'url', + * name: 'CTA Button URL', + * description: 'Destination URL for the call-to-action button', + * }) + * ctaUrl!: string;` + * + * @example + * // Input (Enum field): + * { + * name: 'variant', + * tsType: 'string', + * optional: false, + * hasConfig: true, + * config: { + * type: 'enum', + * name: 'Button Variant', + * values: ['primary', 'secondary', 'outline'], + * defaultValue: 'primary' + * } + * } + * + * // Output: + * `@AttributeDefinition({ + * type: 'enum', + * name: 'Button Variant', + * defaultValue: 'primary', + * values: ['primary', 'secondary', 'outline'], + * }) + * variant!: string;` + * + * @internal + */ +function generateConfiguredAttribute(attr: AttributeContext): string { + if (!attr.config) { + return generateSimpleAttribute(attr); + } + + const config = attr.config; + const configLines: string[] = []; + + if (config.id) { + configLines.push(` id: '${config.id}',`); + } + if (config.name) { + configLines.push(` name: '${config.name}',`); + } + if (config.type) { + configLines.push(` type: '${config.type}',`); + } + if (config.description) { + configLines.push(` description: '${config.description}',`); + } + if (config.defaultValue !== undefined) { + const valueStr = + typeof config.defaultValue === 'string' ? `'${config.defaultValue}'` : JSON.stringify(config.defaultValue); + configLines.push(` defaultValue: ${valueStr},`); + } + if (config.required !== undefined) { + configLines.push(` required: ${config.required},`); + } + if (config.values && config.values.length > 0) { + configLines.push(` values: [${config.values.map((v) => `'${v}'`).join(', ')}],`); + } + + return ` @AttributeDefinition({ +${configLines.join('\n')} + }) + ${attr.name}${attr.optional ? '?' : '!'}: ${attr.tsType};`; +} + +/** + * Generate import statements for Page Designer decorators + * + * **Decision logic:** + * - Always imports `Component` (required for all decorated components) + * - Conditionally imports `AttributeDefinition` (if component has editable props) + * - Conditionally imports `RegionDefinition` (if component has nested content areas) + * + * **Why conditional:** + * Avoids unused imports that would trigger linting warnings. + * + * @param context - Metadata context indicating what's needed + * @returns TypeScript import statements with trailing newlines + * + * @example + * // Component with attributes only: + * generateImports({ needsImports: true, hasAttributes: true, hasRegions: false }) + * // => `import { Component } from '@/lib/decorators/component'; + * // import { AttributeDefinition } from '@/lib/decorators/attribute-definition';\n\n` + * + * @example + * // Component with regions: + * generateImports({ needsImports: true, hasAttributes: true, hasRegions: true }) + * // => All three decorators imported + * + * @internal + */ +function generateImports(context: MetadataContext): string { + if (!context.needsImports) { + return ''; + } + + const imports: string[] = [`import { Component } from '@/lib/decorators/component';`]; + + if (context.hasAttributes) { + imports.push(`import { AttributeDefinition } from '@/lib/decorators/attribute-definition';`); + } + + if (context.hasRegions) { + imports.push(`import { RegionDefinition } from '@/lib/decorators';`); + } + + return `${imports.join('\n')}\n\n`; +} + +/** + * Generate @RegionDefinition decorator for nested content areas + * + * **Regions** define areas where merchants can add nested components in Page Designer. + * Common use cases: + * - Layout containers (grid cells, columns) + * - Content sections (header, body, footer) + * - Tab panels, accordion items + * + * **Configuration options:** + * - `id`: Unique identifier for the region + * - `name`: Display name in Page Designer + * - `description`: Merchant guidance + * - `maxComponents`: Limit number of nested components + * - `componentTypeInclusions`: Whitelist of allowed component types + * - `componentTypeExclusions`: Blacklist of disallowed component types + * + * @param context - Metadata context with region definitions + * @returns TypeScript code for @RegionDefinition decorator or empty string + * + * @example + * // Simple region: + * { + * hasRegions: true, + * regions: [{ + * id: 'main', + * name: 'Main Content Area', + * description: 'Add content components here' + * }] + * } + * // => `@RegionDefinition([ + * // { + * // id: 'main', + * // name: 'Main Content Area', + * // description: 'Add content components here', + * // } + * // ])\n` + * + * @example + * // Constrained region: + * { + * hasRegions: true, + * regions: [{ + * id: 'grid', + * name: 'Product Grid', + * maxComponents: 12, + * componentTypeInclusions: ['product-tile', 'product-card'] + * }] + * } + * + * @internal + */ +function generateRegionDefinition(context: MetadataContext): string { + if (!context.hasRegions || context.regions.length === 0) { + return ''; + } + + const regionsDef = context.regions + .map((region) => { + const lines: string[] = [` {`, ` id: '${region.id}',`, ` name: '${region.name}',`]; + + if (region.description) { + lines.push(` description: '${region.description}',`); + } + if (region.maxComponents !== undefined) { + lines.push(` maxComponents: ${region.maxComponents},`); + } + if (region.componentTypeInclusions && region.componentTypeInclusions.length > 0) { + lines.push( + ` componentTypeInclusions: [${region.componentTypeInclusions.map((t) => `'${t}'`).join(', ')}],`, + ); + } + if (region.componentTypeExclusions && region.componentTypeExclusions.length > 0) { + lines.push( + ` componentTypeExclusions: [${region.componentTypeExclusions.map((t) => `'${t}'`).join(', ')}],`, + ); + } + + lines.push(` }`); + return lines.join('\n'); + }) + .join(',\n'); + + return `@RegionDefinition([\n${regionsDef}\n])\n`; +} + +/** + * Generate complete Page Designer decorator code for a React component + * + * **This is the main code generation function.** + * + * Produces a TypeScript class with decorators that: + * 1. Registers the component in Page Designer + * 2. Defines editable attributes (props) + * 3. Optionally defines nested content regions + * + * **Output structure:** + * ```typescript + * import { Component } from '...'; + * import { AttributeDefinition } from '...'; + * + * @Component('component-id', { + * name: 'Component Name', + * description: '...', + * group: 'category' + * }) + * @RegionDefinition([...]) // Optional + * export class ComponentMetadata { + * @AttributeDefinition({ ... }) + * prop1!: string; + * + * @AttributeDefinition() + * prop2?: number; + * } + * ``` + * + * **Generated code must be:** + * - Added to the component file (after imports, before component) + * - Compiled with TypeScript + * - Used by `generate_page_designer_metadata` tool to create JSON metadata + * + * @param context - Complete metadata context + * @returns TypeScript code string ready to paste into component file + * + * @example + * // Simple component with attributes: + * generateDecoratorCode({ + * needsImports: true, + * componentId: 'hero-banner', + * componentName: 'Hero Banner', + * componentDescription: 'Large hero section with image and CTA', + * componentGroup: 'content', + * metadataClassName: 'HeroBannerMetadata', + * hasAttributes: true, + * hasRegions: false, + * hasLoader: false, + * regions: [], + * attributes: [ + * { name: 'title', tsType: 'string', optional: false, hasConfig: false }, + * { name: 'imageUrl', tsType: 'string', optional: false, hasConfig: true, + * config: { type: 'image', name: 'Background Image' } } + * ] + * }) + * + * @example + * // Layout component with regions: + * generateDecoratorCode({ + * needsImports: true, + * componentId: 'two-column', + * componentName: 'Two Column Layout', + * componentDescription: 'Side-by-side content layout', + * componentGroup: 'layout', + * metadataClassName: 'TwoColumnMetadata', + * hasAttributes: false, + * hasRegions: true, + * hasLoader: false, + * regions: [ + * { id: 'left', name: 'Left Column' }, + * { id: 'right', name: 'Right Column' } + * ], + * attributes: [] + * }) + * + * @public + */ +export function generateDecoratorCode(context: MetadataContext): string { + const imports = generateImports(context); + + const componentDecorator = `@Component('${context.componentId}', { + name: '${context.componentName}', + description: '${context.componentDescription}',${context.componentGroup ? `\n group: '${context.componentGroup}',` : ''} +})`; + + const regionDefinition = generateRegionDefinition(context); + + const attributes = context.attributes + .map((attr) => { + return attr.hasConfig ? generateConfiguredAttribute(attr) : generateSimpleAttribute(attr); + }) + .join('\n\n'); + + return `${imports}${componentDecorator} +${regionDefinition}export class ${context.metadataClassName} { +${attributes} +}`; +} diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/README.md b/packages/b2c-dx-mcp/src/tools/storefrontnext/README.md index 9ef19312..cf45a994 100644 --- a/packages/b2c-dx-mcp/src/tools/storefrontnext/README.md +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/README.md @@ -80,10 +80,59 @@ MCP tools for Storefront Next development with React Server Components. } ``` +### `storefront_next_page_designer_decorator` + +Add Page Designer decorators (`@Component`, `@AttributeDefinition`, `@RegionDefinition`) to existing React components for Storefront Next. + +**Status**: ✅ Implemented (non-GA - use `--allow-non-ga-tools` flag) + +**Use cases**: + +- Add Page Designer support to new components +- Convert existing components to be Page Designer-compatible +- Generate decorator code automatically or interactively +- Configure component attributes and regions for Page Designer + +**Parameters**: + +- `component` (required, string): Component name (e.g., "ProductCard") or file path +- `autoMode` (optional, boolean): Enable auto mode for quick setup +- `searchPaths` (optional, array): Additional directories to search for components +- `componentId` (optional, string): Override component ID +- `conversationContext` (optional, object): For interactive mode workflow steps + +**Returns**: Generated decorator code and instructions for adding to component file + +**Example usage**: + +```json +// Auto mode (quick setup) +{ + "name": "storefront_next_page_designer_decorator", + "arguments": { + "component": "ProductCard", + "autoMode": true + } +} + +// Interactive mode (step-by-step) +{ + "name": "storefront_next_page_designer_decorator", + "arguments": { + "component": "Hero", + "conversationContext": { + "step": "analyze" + } + } +} +``` + ## Implementation Details ### Architecture +#### `storefront_next_development_guidelines` + The tool loads content from markdown files in the `content/` directory: - **Content source**: Markdown files loaded at runtime from `packages/b2c-dx-mcp/content/*.md` @@ -91,7 +140,7 @@ The tool loads content from markdown files in the `content/` directory: - **Section-Based**: Individual markdown files per topic (~100-200 lines each) - **Default behavior**: Returns 4 sections by default for comprehensive coverage -### Content Structure +**Content Structure**: Each section markdown file includes: @@ -100,14 +149,14 @@ Each section markdown file includes: - Quick reference snippets - Framework-specific patterns for React Server Components -### Behavior +**Behavior**: - **No sections specified**: Returns default comprehensive set (`quick-reference`, `data-fetching`, `components`, `testing`) - **Single section**: Returns content directly without separators - **Multiple sections**: Combines content with `---` separators and includes instructions for full content display - **Empty array**: Returns empty string -### Benefits +**Benefits**: ✅ **Token Efficient**: Returns only relevant content (200-500 lines vs 20K+ full doc) ✅ **Modular**: Access specific sections as needed @@ -115,6 +164,44 @@ Each section markdown file includes: ✅ **Always Current**: Content loaded from markdown files (easy to update) ✅ **Comprehensive Default**: Returns key sections by default for immediate value +#### `storefront_next_page_designer_decorator` + +The tool uses a rule-based architecture with TypeScript template literals for generating Page Designer decorators: + +- **Rule Rendering**: Pure TypeScript functions that return strings based on typed context +- **Type Safety**: Every rule has a strongly-typed context interface checked at compile time +- **Template Generation**: Code generation uses pure functions for decorator creation +- **Component Discovery**: Automatically searches common component directories (e.g., `src/components/**`, `app/components/**`) + +**Key Features**: + +- **Name-Based Lookup**: Find components by name (e.g., "ProductCard") without knowing paths +- **Auto-Discovery**: Searches common component directories automatically +- **Type-Safe**: Full TypeScript type inference for all contexts +- **Fast**: Direct function execution, no file I/O or compilation overhead +- **Flexible Input**: Supports component names or file paths + +**Modes**: + +- **Auto Mode**: Generates decorators immediately with sensible defaults +- **Interactive Mode**: Multi-step workflow with user confirmation at each stage + +**Component Discovery**: + +The tool automatically searches for components in these locations (in order): + +1. `src/components/**` (PascalCase and kebab-case) +2. `app/components/**` +3. `components/**` +4. `src/**` (broader search) +5. Custom paths (if provided via `searchPaths`) + +**Working Directory**: + +Component discovery uses the working directory resolved from `--working-directory` flag or `SFCC_WORKING_DIRECTORY` environment variable (via Services). This ensures searches start from the correct project directory, especially when MCP clients spawn servers from the home directory. + +**See also**: [Detailed documentation](./page-designer-decorator/README.md) for complete usage guide, architecture details, and examples. + ## Placeholder Tools The following tools are placeholders awaiting implementation: @@ -123,7 +210,6 @@ The following tools are placeholders awaiting implementation: - `storefront_next_figma_to_component_workflow` - Convert Figma designs to Storefront Next components - `storefront_next_generate_component` - Generate a new Storefront Next component - `storefront_next_map_tokens_to_theme` - Map design tokens to Storefront Next theme configuration -- `storefront_next_design_decorator` - Apply design decorators to Storefront Next components - `storefront_next_generate_page_designer_metadata` - Generate Page Designer metadata for Storefront Next components Use `--allow-non-ga-tools` flag to enable placeholder tools. diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts index 9a0c0870..a6f97a74 100644 --- a/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts @@ -17,7 +17,6 @@ * - `storefront_next_figma_to_component_workflow` - Convert Figma to components * - `storefront_next_generate_component` - Generate new components * - `storefront_next_map_tokens_to_theme` - Map design tokens - * - `storefront_next_design_decorator` - Apply design decorators * - `storefront_next_generate_page_designer_metadata` - Generate Page Designer metadata * * @module tools/storefrontnext @@ -28,6 +27,7 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; import {createDeveloperGuidelinesTool} from './developer-guidelines.js'; +import {createPageDesignerDecoratorTool} from '../page-designer-decorator/index.js'; /** * Common input type for placeholder tools. @@ -100,6 +100,7 @@ function createPlaceholderTool(name: string, description: string, loadServices: export function createStorefrontNextTools(loadServices: () => Services): McpTool[] { return [ createDeveloperGuidelinesTool(loadServices), + createPageDesignerDecoratorTool(loadServices), createPlaceholderTool( 'storefront_next_site_theming', 'Configure and manage site theming for Storefront Next', @@ -120,11 +121,6 @@ export function createStorefrontNextTools(loadServices: () => Services): McpTool 'Map design tokens to Storefront Next theme configuration', loadServices, ), - createPlaceholderTool( - 'storefront_next_design_decorator', - 'Apply design decorators to Storefront Next components', - loadServices, - ), createPlaceholderTool( 'storefront_next_generate_page_designer_metadata', 'Generate Page Designer metadata for Storefront Next components', diff --git a/packages/b2c-dx-mcp/test/tools/page-designer-decorator/README.md b/packages/b2c-dx-mcp/test/tools/page-designer-decorator/README.md new file mode 100644 index 00000000..14e35137 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/page-designer-decorator/README.md @@ -0,0 +1,155 @@ +# Testing Page Designer Decorator Tool + +## Test Status + +The page-designer-decorator tool has comprehensive unit tests covering: +- ✅ Tool metadata (name, description, toolsets, isGA) +- ✅ Mode selection flow +- ✅ Auto mode (basic, type inference, complex/UI props exclusion, edge cases) +- ✅ Interactive mode (all steps: analyze, select_props, configure_attrs, configure_regions, confirm_generation) +- ✅ Component resolution (name-based, kebab-case, nested, path-based, custom searchPaths, name collisions) +- ✅ Error handling (invalid input, invalid step name, missing parameters) +- ✅ Input validation +- ✅ Edge cases (no props, only complex props, optional props, union types, already decorated components) +- ✅ Environment variables (SFCC_WORKING_DIRECTORY) + +All tests use the standard Mocha test framework and run with `pnpm test`. + +## Testing Approaches + +### 1. Unit Tests (Automated) + +Run the test suite: + +```bash +cd packages/b2c-dx-mcp +pnpm run test:agent -- test/tools/page-designer-decorator/index.test.ts +``` + +### 2. MCP Inspector (Interactive Testing) + +Use the MCP Inspector to test the tool interactively: + +```bash +cd packages/b2c-dx-mcp +pnpm run inspect:dev +``` + +Then in the inspector: +1. Click **Connect** +2. Click **List Tools** - you should see `storefront_next_page_designer_decorator` +3. Click on the tool to test it with real inputs + +### 3. CLI Testing + +Test via command line: + +```bash +# List all tools (should include storefront_next_page_designer_decorator) +npx mcp-inspector --cli node bin/dev.js --toolsets STOREFRONTNEXT --allow-non-ga-tools --method tools/list + +# Call the tool +npx mcp-inspector --cli node bin/dev.js --toolsets STOREFRONTNEXT --allow-non-ga-tools \ + --method tools/call \ + --tool-name storefront_next_page_designer_decorator \ + --args '{"component": "MyComponent"}' +``` + +### 4. Running Tests Against a Local Storefront Next Installation + +The Mocha test suite supports testing against a real Storefront Next installation by setting `SFCC_WORKING_DIRECTORY`: + +```bash +cd packages/b2c-dx-mcp +SFCC_WORKING_DIRECTORY=/path/to/storefront-next \ + pnpm run test:agent -- test/tools/page-designer-decorator/index.test.ts +``` + +Or set it as an environment variable: +```bash +export SFCC_WORKING_DIRECTORY=/path/to/storefront-next +cd packages/b2c-dx-mcp +pnpm run test:agent -- test/tools/page-designer-decorator/index.test.ts +``` + +**Important Notes for Real Project Mode**: +- Component discovery searches in your real Storefront Next project (`SFCC_WORKING_DIRECTORY`) +- Tests create temporary directories for test components (not in your real project) +- Tests will **not** modify your real project files (read-only) +- Tests will use existing components from your real project if they exist +- The real project directory is preserved after testing +- To test with specific components, ensure they exist in your real project's `src/components/` directory + +**Alternative Testing Methods**: +- **MCP Inspector**: Interactive UI testing (see section 2 above) +- **CLI Testing**: Command-line testing (see section 3 above) +- **Manual Test Plan**: Full integration testing including Business Manager and Page Designer (see [manual test plan](../../../../../Documents/page-designer-decorator-manual-test-plan.md) for TC-7.x tests) + +### 5. Manual Testing with Real Components + +1. Set up a Storefront Next project (or use an existing one) +2. Create a test component: + +```tsx +// src/components/TestComponent.tsx +export interface TestComponentProps { + title: string; + description?: string; +} + +export default function TestComponent({title, description}: TestComponentProps) { + return
{title}
; +} +``` + +3. Set environment variable: +```bash +export SFCC_WORKING_DIRECTORY=/path/to/storefront-next +``` + +4. Use the tool via MCP Inspector or your IDE's MCP integration + +### 6. Test Scenarios + +#### Mode Selection +```json +{ + "component": "TestComponent" +} +``` +Expected: Returns mode selection instructions + +#### Auto Mode +```json +{ + "component": "src/components/TestComponent.tsx", + "autoMode": true +} +``` +Expected: Generates decorators automatically + +#### Interactive Mode - Analyze Step +```json +{ + "component": "src/components/TestComponent.tsx", + "conversationContext": { + "step": "analyze" + } +} +``` +Expected: Returns component analysis + +## Troubleshooting + +### Component Not Found Errors + +If you get "Component not found" errors: +1. Verify `SFCC_WORKING_DIRECTORY` is set correctly +2. Check that the component file exists at the expected path +3. Try using the full relative path: `"component": "src/components/MyComponent.tsx"` + +### Validation Errors + +If you get Zod validation errors: +- Check that all required fields are provided +- Verify field types match the schema (e.g., `component` must be a string) diff --git a/packages/b2c-dx-mcp/test/tools/page-designer-decorator/index.test.ts b/packages/b2c-dx-mcp/test/tools/page-designer-decorator/index.test.ts new file mode 100644 index 00000000..53b1ee76 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/page-designer-decorator/index.test.ts @@ -0,0 +1,970 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {createPageDesignerDecoratorTool} from '../../../src/tools/page-designer-decorator/index.js'; +import {Services} from '../../../src/services.js'; +import type {ToolResult} from '../../../src/utils/types.js'; +import {existsSync, mkdirSync, writeFileSync, rmSync} from 'node:fs'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {createMockResolvedConfig} from '../../test-helpers.js'; + +/** + * Helper to extract text from a ToolResult. + * Throws if the first content item is not a text type. + * + * @param result - The ToolResult to extract text from + * @returns The text content from the first content item + * @throws {Error} If the first content item is not a text type + */ +function getResultText(result: ToolResult): string { + const content = result.content[0]; + if (content.type !== 'text') { + throw new Error(`Expected text content, got ${content.type}`); + } + return content.text; +} + +/** + * Create a mock services instance for testing. + * + * @param workingDirectory - Optional working directory (defaults to process.cwd()) + * @returns A new Services instance with empty configuration + */ +function createMockServices(workingDirectory?: string): Services { + const config = createMockResolvedConfig({workingDirectory}); + return new Services({resolvedConfig: config}); +} + +/** + * Create a temporary test component file. + * Creates components in the standard location that the tool searches for (`src/components/`). + * + * The component will have: + * - A Props interface with the specified props + * - A default export function component + * - Proper copyright header + * + * @param dir - The test directory root where the component should be created + * @param componentName - The name of the component (e.g., "TestComponent") + * @param props - Optional props string in the format "propName: type; propName2: type;" + * If not provided, defaults to "title: string;" + * @returns The absolute path to the created component file + * + * @example + * ```typescript + * const path = createTestComponent(testDir, 'MyComponent', 'title: string; count: number;'); + * // Creates: {testDir}/src/components/MyComponent.tsx + * ``` + */ +function createTestComponent(dir: string, componentName: string, props?: string): string { + // Create in src/components/ which is the standard search location + const componentPath = path.join(dir, 'src', 'components', `${componentName}.tsx`); + mkdirSync(path.dirname(componentPath), {recursive: true}); + + // Extract prop names for the component function + const propNames = props + ? props + .split(';') + .map((p) => p.trim()) + .filter((p) => p.length > 0) + .map((p) => p.split(':')[0].trim()) + .join(', ') + : 'title'; + + const componentContent = `/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface ${componentName}Props { + ${props || 'title: string;'} +} + +export default function ${componentName}({${propNames}}: ${componentName}Props) { + return
{${propNames.split(',')[0].trim()}}
; +} +`; + + writeFileSync(componentPath, componentContent, 'utf8'); + return componentPath; +} + +/** + * Tests for the page-designer-decorator MCP tool. + * + * This test suite covers: + * - Tool metadata (name, description, toolsets, isGA) + * - Mode selection workflow + * - Auto mode decorator generation (including edge cases: no props, only complex props, optional props, union types, already decorated) + * - Interactive mode workflow (all steps) + * - Component resolution (by name, kebab-case, nested paths, path, custom searchPaths, name collisions) + * - Input validation + * - Error handling (invalid input, invalid step name, missing parameters) + * - Output format validation + * + * Tests use temporary directories and mock components to avoid dependencies + * on real project files. + */ +describe('tools/page-designer-decorator', () => { + let services: Services; + let testDir: string; + let originalCwd: string; + + beforeEach(() => { + // Create a temporary directory for test components + testDir = path.join(tmpdir(), `b2c-mcp-test-${Date.now()}`); + mkdirSync(testDir, {recursive: true}); + originalCwd = process.cwd(); + process.chdir(testDir); + // Create services with workingDirectory set to test directory + services = createMockServices(testDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (existsSync(testDir)) { + rmSync(testDir, {recursive: true, force: true}); + } + }); + + describe('tool metadata', () => { + it('should have correct tool name', () => { + const tool = createPageDesignerDecoratorTool(() => services); + expect(tool.name).to.equal('storefront_next_page_designer_decorator'); + }); + + it('should have comprehensive description', () => { + const tool = createPageDesignerDecoratorTool(() => services); + const desc = tool.description; + + // Should mention Page Designer + expect(desc).to.include('Page Designer'); + expect(desc).to.include('decorator'); + + // Should mention modes + expect(desc).to.match(/AUTO MODE|auto mode/i); + expect(desc).to.match(/INTERACTIVE MODE|interactive mode/i); + + // Should mention key features + expect(desc).to.include('@Component'); + expect(desc).to.include('@AttributeDefinition'); + }); + + it('should be in STOREFRONTNEXT toolset', () => { + const tool = createPageDesignerDecoratorTool(() => services); + expect(tool.toolsets).to.include('STOREFRONTNEXT'); + expect(tool.toolsets).to.have.lengthOf(1); + }); + + it('should not be GA (generally available)', () => { + const tool = createPageDesignerDecoratorTool(() => services); + expect(tool.isGA).to.be.false; + }); + }); + + describe('mode selection', () => { + it('should show mode selection when called with only component name', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'TestComponent'); + + const result = await tool.handler({ + component: 'TestComponent', + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should present mode selection options + expect(text).to.match(/mode|Mode/i); + expect(text).to.match(/auto|Auto/i); + expect(text).to.match(/interactive|Interactive/i); + expect(text).to.include('TestComponent'); + }); + + it('should use workingDirectory from Services', async () => { + const customDir = path.join(tmpdir(), `b2c-mcp-test-custom-${Date.now()}`); + mkdirSync(customDir, {recursive: true}); + createTestComponent(customDir, 'CustomComponent'); + + // Create services with custom workingDirectory + const customServices = createMockServices(customDir); + const tool = createPageDesignerDecoratorTool(() => customServices); + + const result = await tool.handler({ + component: 'CustomComponent', + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + expect(text).to.match(/mode|Mode/i); + + rmSync(customDir, {recursive: true, force: true}); + }); + }); + + describe('auto mode', () => { + it('should generate decorators in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'AutoComponent', 'title: string;'); + + const result = await tool.handler({ + component: 'AutoComponent', + autoMode: true, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should generate decorator code + expect(text).to.include('@Component'); + expect(text).to.include('@AttributeDefinition'); + expect(text).to.include('AutoComponent'); + expect(text).to.include('title'); + }); + + it('should handle component with multiple props in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent( + testDir, + 'MultiPropComponent', + `title: string; +description: string; +imageUrl: string;`, + ); + + const result = await tool.handler({ + component: 'MultiPropComponent', + autoMode: true, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should include decorators for multiple props + expect(text).to.include('@Component'); + expect(text).to.include('title'); + expect(text).to.include('description'); + expect(text).to.include('imageUrl'); + }); + + it('should exclude complex props in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent( + testDir, + 'ComplexPropsComponent', + `title: string; +onClick: () => void; +config: { key: string; value: number };`, + ); + + const result = await tool.handler({ + component: 'ComplexPropsComponent', + autoMode: true, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should include simple props in generated decorators + expect(text).to.include('title'); + // Complex props should not appear in @AttributeDefinition decorators + // (they might appear in instructions, but not in the actual decorator code) + const decoratorCodeMatch = text.match(/@AttributeDefinition[\s\S]*?\)/g); + if (decoratorCodeMatch) { + const decoratorCode = decoratorCodeMatch.join('\n'); + expect(decoratorCode).to.not.include('onClick'); + expect(decoratorCode).to.not.include('config'); + } + }); + + it('should exclude UI-only props in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent( + testDir, + 'UIPropsComponent', + `title: string; +className: string; +style: React.CSSProperties;`, + ); + + const result = await tool.handler({ + component: 'UIPropsComponent', + autoMode: true, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should include content props in generated decorators + expect(text).to.include('title'); + // UI-only props should not appear in @AttributeDefinition decorators + // (they might appear in instructions, but not in the actual decorator code) + const decoratorCodeMatch = text.match(/@AttributeDefinition[\s\S]*?\)/g); + if (decoratorCodeMatch) { + const decoratorCode = decoratorCodeMatch.join('\n'); + expect(decoratorCode).to.not.include('className'); + expect(decoratorCode).to.not.include('style'); + } + }); + + it('should handle component already decorated in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + const decoratedPath = path.join(testDir, 'src', 'components', 'DecoratedComponent.tsx'); + mkdirSync(path.dirname(decoratedPath), {recursive: true}); + writeFileSync( + decoratedPath, + `import {Component} from '@salesforce/retail-react-app/app/components/page-designer'; + +@Component({ + id: 'existing-component', + name: 'Existing Component', +}) +export class DecoratedComponentMetadata { + @AttributeDefinition() + title!: string; +} + +export interface DecoratedComponentProps { + title: string; +} + +export default function DecoratedComponent({title}: DecoratedComponentProps) { + return
{title}
; +}`, + 'utf8', + ); + + const result = await tool.handler({ + component: 'DecoratedComponent', + autoMode: true, + }); + + // Should handle already-decorated components gracefully + // May return an error or provide guidance + expect(result).to.exist; + const text = getResultText(result); + // Should mention the component is already decorated or provide appropriate guidance + expect(text).to.match(/decorated|already|existing|Component/i); + }); + + it('should handle component with no props in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + const emptyPath = path.join(testDir, 'src', 'components', 'EmptyProps.tsx'); + mkdirSync(path.dirname(emptyPath), {recursive: true}); + writeFileSync( + emptyPath, + `export interface EmptyPropsProps {} +export default function EmptyProps({}: EmptyPropsProps) { return
Empty
; }`, + 'utf8', + ); + + const result = await tool.handler({ + component: 'EmptyProps', + autoMode: true, + }); + + // Should handle components with no props gracefully + expect(result).to.exist; + const text = getResultText(result); + // Should generate decorator code even with no props (just @Component, no @AttributeDefinition) + expect(text).to.include('@Component'); + expect(text).to.include('EmptyProps'); + }); + + it('should handle component with only complex props in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent( + testDir, + 'ComplexOnlyComponent', + `onClick: () => void; +config: { key: string }; +data: Array<{id: number}>;`, + ); + + const result = await tool.handler({ + component: 'ComplexOnlyComponent', + autoMode: true, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should generate decorator code with just @Component (no @AttributeDefinition since all props are complex) + expect(text).to.include('@Component'); + expect(text).to.include('ComplexOnlyComponent'); + // Should not include complex props in decorators + const decoratorCodeMatch = text.match(/@AttributeDefinition[\s\S]*?\)/g); + if (decoratorCodeMatch) { + const decoratorCode = decoratorCodeMatch.join('\n'); + expect(decoratorCode).to.not.include('onClick'); + expect(decoratorCode).to.not.include('config'); + expect(decoratorCode).to.not.include('data'); + } + }); + + it('should handle component with optional props in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent( + testDir, + 'OptionalPropsComponent', + `title?: string; +count?: number;`, + ); + + const result = await tool.handler({ + component: 'OptionalPropsComponent', + autoMode: true, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should include optional props in generated decorators + expect(text).to.include('@Component'); + expect(text).to.include('OptionalPropsComponent'); + expect(text).to.include('title'); + expect(text).to.include('count'); + }); + + it('should handle component with union types in auto mode', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent( + testDir, + 'UnionTypesComponent', + `status: 'active' | 'inactive'; +value: string | number;`, + ); + + const result = await tool.handler({ + component: 'UnionTypesComponent', + autoMode: true, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should handle union types appropriately + expect(text).to.include('@Component'); + expect(text).to.include('UnionTypesComponent'); + expect(text).to.include('status'); + expect(text).to.include('value'); + }); + }); + + describe('interactive mode', () => { + describe('analyze step', () => { + it('should analyze component in analyze step', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'AnalyzeComponent', 'title: string;'); + + const result = await tool.handler({ + component: 'AnalyzeComponent', + conversationContext: { + step: 'analyze', + }, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should show analysis results + expect(text).to.match(/component|Component/i); + expect(text).to.match(/prop|Prop|attribute|Attribute/i); + expect(text).to.include('AnalyzeComponent'); + expect(text).to.include('title'); + }); + + it('should categorize props correctly', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent( + testDir, + 'CategorizedComponent', + `title: string; +onClick: () => void; +className: string;`, + ); + + const result = await tool.handler({ + component: 'CategorizedComponent', + conversationContext: { + step: 'analyze', + }, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should identify editable props + expect(text).to.include('title'); + // Should mention props analysis (may use different terminology) + expect(text).to.match(/prop|Prop|attribute|Attribute|editable|suitable/i); + // Should mention complex or UI props (may be described differently) + expect(text).to.match(/complex|Complex|UI|ui|exclude|skip/i); + }); + }); + + describe('select_props step', () => { + it('should confirm selected props', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'SelectPropsComponent', 'title: string; description: string;'); + + const result = await tool.handler({ + component: 'SelectPropsComponent', + conversationContext: { + step: 'select_props', + selectedProps: ['title', 'description'], + componentMetadata: { + id: 'select-props-component', + name: 'Select Props Component', + description: 'Test component', + }, + }, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should confirm selections + expect(text).to.include('title'); + expect(text).to.include('description'); + expect(text).to.include('Select Props Component'); + }); + + it('should require component metadata', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'MissingMetadataComponent'); + + const result = await tool.handler({ + component: 'MissingMetadataComponent', + conversationContext: { + step: 'select_props', + selectedProps: ['title'], + // Missing componentMetadata + }, + }); + + // Should return error when metadata is missing + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.match(/metadata|Metadata/i); + }); + }); + + describe('configure_attrs step', () => { + it('should provide attribute configuration instructions', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'ConfigureAttrsComponent', 'imageUrl: string; description: string;'); + + const result = await tool.handler({ + component: 'ConfigureAttrsComponent', + conversationContext: { + step: 'configure_attrs', + selectedProps: ['imageUrl', 'description'], + }, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should provide configuration guidance + expect(text).to.match(/attribute|Attribute|configure|Configure/i); + expect(text).to.include('imageUrl'); + expect(text).to.include('description'); + }); + + it('should suggest types for props', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'TypeSuggestionsComponent', 'imageUrl: string; productId: string;'); + + const result = await tool.handler({ + component: 'TypeSuggestionsComponent', + conversationContext: { + step: 'configure_attrs', + selectedProps: ['imageUrl', 'productId'], + }, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should suggest appropriate types + expect(text).to.match(/url|image|product/i); + }); + }); + + describe('configure_regions step', () => { + it('should provide region configuration instructions', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'RegionsComponent'); + + const result = await tool.handler({ + component: 'RegionsComponent', + conversationContext: { + step: 'configure_regions', + }, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should provide region configuration guidance + expect(text).to.match(/region|Region/i); + }); + }); + + describe('confirm_generation step', () => { + it('should generate decorator code when all context provided', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'ConfirmComponent', 'title: string;'); + + const result = await tool.handler({ + component: 'ConfirmComponent', + conversationContext: { + step: 'confirm_generation', + selectedProps: ['title'], + componentMetadata: { + id: 'confirm-component', + name: 'Confirm Component', + description: 'Test component', + }, + attributeConfig: { + title: { + type: 'string', + name: 'Title', + }, + }, + }, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + + // Should generate complete decorator code + expect(text).to.include('@Component'); + expect(text).to.include('@AttributeDefinition'); + expect(text).to.include('ConfirmComponent'); + expect(text).to.include('title'); + }); + + it('should require component metadata', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'MissingMetadataConfirmComponent'); + + const result = await tool.handler({ + component: 'MissingMetadataConfirmComponent', + conversationContext: { + step: 'confirm_generation', + // Missing componentMetadata + }, + }); + + // Should return error when metadata is missing + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.match(/metadata|Metadata/i); + }); + }); + }); + + describe('error handling', () => { + it('should handle non-existent component gracefully', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + + const result = await tool.handler({ + component: 'NonExistentComponent', + }); + + // Should return an error result + expect(result.isError).to.be.true; + const text = getResultText(result); + expect(text).to.match(/not found|error|Error/i); + }); + + it('should handle invalid input gracefully', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + + // Invalid input should be caught by zod validation + const result = await tool.handler({ + component: 123, // Invalid type + } as unknown as Record); + + // Should return an error result + expect(result.isError).to.be.true; + }); + + it('should handle invalid step name', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'TestComponent'); + + const result = await tool.handler({ + component: 'TestComponent', + conversationContext: {step: 'invalid_step'}, + } as unknown as Record); + + // Should return an error result for invalid step + expect(result.isError).to.be.true; + }); + + it('should handle missing required parameter', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + + const result = await tool.handler({} as unknown as Record); + + // Should return an error result + expect(result.isError).to.be.true; + }); + }); + + describe('component resolution', () => { + it('should find component by name in standard location', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'StandardLocationComponent'); + + const result = await tool.handler({ + component: 'StandardLocationComponent', + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + expect(text).to.include('StandardLocationComponent'); + }); + + it('should find component by kebab-case name', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + const kebabPath = path.join(testDir, 'src', 'components', 'product-card.tsx'); + mkdirSync(path.dirname(kebabPath), {recursive: true}); + writeFileSync( + kebabPath, + `export interface ProductCardProps { title: string; } +export default function ProductCard({title}: ProductCardProps) { return
{title}
; }`, + 'utf8', + ); + + const result = await tool.handler({ + component: 'product-card', + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + expect(text).to.match(/ProductCard|product-card/i); + }); + + it('should find nested component by name', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + const nestedPath = path.join(testDir, 'src', 'components', 'hero', 'Hero.tsx'); + mkdirSync(path.dirname(nestedPath), {recursive: true}); + writeFileSync( + nestedPath, + `export interface HeroProps { title: string; } +export default function Hero({title}: HeroProps) { return
{title}
; }`, + 'utf8', + ); + + const result = await tool.handler({ + component: 'Hero', + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + expect(text).to.include('Hero'); + }); + + it('should find component by path', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + const componentPath = createTestComponent(testDir, 'PathComponent'); + + const result = await tool.handler({ + component: path.relative(testDir, componentPath), + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + expect(text).to.include('PathComponent'); + }); + + it('should use searchPaths when provided', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + // Create component in a custom location + const customDir = path.join(testDir, 'custom', 'components'); + mkdirSync(customDir, {recursive: true}); + const componentPath = path.join(customDir, 'CustomLocationComponent.tsx'); + writeFileSync( + componentPath, + `export interface CustomLocationComponentProps { + title: string; +} + +export default function CustomLocationComponent({title}: CustomLocationComponentProps) { + return
{title}
; +} +`, + 'utf8', + ); + + const result = await tool.handler({ + component: 'CustomLocationComponent', + searchPaths: ['custom/components'], + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + expect(text).to.include('CustomLocationComponent'); + }); + + it('should handle component name collision', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + // Create component in src/components/ + createTestComponent(testDir, 'CollisionComponent', 'title: string;'); + // Create component with same name in app/components/ + const appPath = path.join(testDir, 'app', 'components', 'CollisionComponent.tsx'); + mkdirSync(path.dirname(appPath), {recursive: true}); + writeFileSync( + appPath, + `export interface CollisionComponentProps { title: string; } +export default function CollisionComponent({title}: CollisionComponentProps) { return
{title}
; }`, + 'utf8', + ); + + const result = await tool.handler({ + component: 'CollisionComponent', + }); + + // Should find one of the components (likely the first one found) + expect(result.isError).to.be.undefined; + const text = getResultText(result); + expect(text).to.include('CollisionComponent'); + }); + }); + + describe('input validation', () => { + it('should accept valid component name', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'ValidComponent'); + + const result = await tool.handler({ + component: 'ValidComponent', + }); + + // Should not error on valid input + expect(result.isError).to.be.undefined; + }); + + it('should accept component path', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + const componentPath = createTestComponent(testDir, 'PathComponent'); + + const result = await tool.handler({ + component: path.relative(testDir, componentPath), + }); + + // Should not error on valid path + expect(result.isError).to.be.undefined; + }); + + it('should accept optional searchPaths', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'SearchComponent'); + + const result = await tool.handler({ + component: 'SearchComponent', + searchPaths: ['src/components'], + }); + + // Should not error with searchPaths + expect(result.isError).to.be.undefined; + }); + + it('should accept optional autoMode flag', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'AutoModeComponent'); + + const result = await tool.handler({ + component: 'AutoModeComponent', + autoMode: true, + }); + + // Should not error with autoMode + expect(result.isError).to.be.undefined; + }); + + it('should accept optional componentId', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'CustomIdComponent'); + + const result = await tool.handler({ + component: 'CustomIdComponent', + componentId: 'custom-component-id', + autoMode: true, + }); + + expect(result.isError).to.be.undefined; + const text = getResultText(result); + expect(text).to.include('custom-component-id'); + }); + + it('should accept conversationContext with all steps', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'ConversationComponent'); + + const steps = ['analyze', 'select_props', 'configure_attrs', 'configure_regions', 'confirm_generation']; + + const results = await Promise.all( + steps.map((step) => + tool.handler({ + component: 'ConversationComponent', + conversationContext: { + step: step as 'analyze' | 'configure_attrs' | 'configure_regions' | 'confirm_generation' | 'select_props', + }, + }), + ), + ); + + // Should not error on valid step + for (const [i, step] of steps.entries()) { + const result = results[i]; + if (step === 'select_props' || step === 'confirm_generation') { + // These steps require metadata, so they'll error without it + // But the step itself should be accepted + expect(result).to.exist; + } else { + expect(result.isError).to.be.undefined; + } + } + }); + }); + + describe('output format', () => { + it('should return text content in ToolResult format', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + createTestComponent(testDir, 'FormatComponent'); + + const result = await tool.handler({ + component: 'FormatComponent', + }); + + expect(result).to.have.property('content'); + expect(result.content).to.be.an('array'); + expect(result.content.length).to.be.greaterThan(0); + expect(result.content[0]).to.have.property('type', 'text'); + expect(result.content[0]).to.have.property('text'); + }); + + it('should return error format when component not found', async () => { + const tool = createPageDesignerDecoratorTool(() => services); + + const result = await tool.handler({ + component: 'NonExistentComponent', + }); + + expect(result.isError).to.be.true; + expect(result.content).to.be.an('array'); + expect(result.content[0]).to.have.property('type', 'text'); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b307dad3..524170d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,12 @@ importers: '@salesforce/b2c-tooling-sdk': specifier: workspace:* version: link:../b2c-tooling-sdk + glob: + specifier: 'catalog:' + version: 13.0.0 + ts-morph: + specifier: ^27.0.0 + version: 27.0.2 yaml: specifier: 2.8.1 version: 2.8.1 @@ -2879,6 +2885,9 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@ts-morph/common@0.28.1': + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -3823,6 +3832,9 @@ packages: resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} engines: {node: '>=16'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -6110,6 +6122,9 @@ packages: pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-case@3.0.4: resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} @@ -7063,6 +7078,9 @@ packages: peerDependencies: typescript: '>=4.0.0' + ts-morph@27.0.2: + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -10606,6 +10624,12 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@ts-morph/common@0.28.1': + dependencies: + minimatch: 10.1.1 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -11675,6 +11699,8 @@ snapshots: cockatiel@3.2.1: {} + code-block-writer@13.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -14177,6 +14203,8 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + path-browserify@1.0.1: {} + path-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -15290,6 +15318,11 @@ snapshots: picomatch: 4.0.3 typescript: 5.9.3 + ts-morph@27.0.2: + dependencies: + '@ts-morph/common': 0.28.1 + code-block-writer: 13.0.3 + ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1