import fs from 'node:fs'; import path from 'node:path'; import { z } from 'zod'; import { config } from '../../config.js'; import { detectProject } from '../../lib/frameworks/detect.js'; import { resolveWorkspacePath } from '../../lib/sandbox.js'; import { errorResult, jsonResult, toErrorMessage } from '../shared.js'; /** * UI component generator. Prefers React (TSX/JSX) or Vue/Nuxt SFCs when a * matching frontend is detected; otherwise writes a plain HTML/CSS/JS snippet. */ export function registerGenerateComponentTool(server) { server.registerTool('generate_component', { title: 'Generate Component', description: 'Scaffold a UI component. Generates a React function component (TSX/JSX) when React is detected, ' + 'a Vue SFC (.vue) when Vue or Nuxt is detected, or a plain HTML/CSS/JS snippet otherwise.', inputSchema: { name: z.string().min(1).describe('Component name, e.g. "UserCard" (PascalCase recommended).'), directory: z .string() .optional() .describe('Directory relative to the frontend project root. Defaults to "src/components".'), style: z.enum(['css-module', 'tailwind', 'none']).optional().default('css-module'), withTest: z.boolean().optional().default(false), clientComponent: z .boolean() .optional() .default(false) .describe('If true (Next.js App Router), prepends "use client" for Client Components. Ignored for Vue/Nuxt.'), overwrite: z.boolean().optional().default(false), }, }, async ({ name, directory, style, withTest, clientComponent, overwrite }) => { try { const detected = detectProject(config.workspaceRoot); const frontend = detected.frontend; if (frontend?.kind === 'react') { return jsonResult(generateReactComponent(frontend.root, name, directory, style, withTest, clientComponent ?? false, overwrite)); } if (frontend?.kind === 'vue' || frontend?.kind === 'nuxt') { return jsonResult(generateVueSfc(frontend.root, frontend.kind, name, directory, style, withTest, overwrite)); } return jsonResult(generatePlainComponent(config.workspaceRoot, name, directory, overwrite)); } catch (error) { return errorResult(toErrorMessage(error)); } }); } function generateReactComponent(frontendRoot, name, directory, style, withTest, clientComponent, overwrite) { const typescript = fs.existsSync(path.join(frontendRoot, 'tsconfig.json')); const ext = typescript ? 'tsx' : 'jsx'; const dir = resolveWorkspacePath(frontendRoot, directory ?? 'src/components'); const componentFile = path.join(dir, `${name}.${ext}`); const files = []; assertNotExists(componentFile, overwrite); fs.mkdirSync(dir, { recursive: true }); const useClient = clientComponent ? `'use client';\n\n` : ''; const cssImport = style === 'css-module' ? `import styles from './${name}.module.css';\n` : ''; const rootClass = style === 'css-module' ? ' className={styles.root}' : style === 'tailwind' ? ' className="p-4"' : ''; fs.writeFileSync(componentFile, `${useClient}${cssImport}export function ${name}() { return (

${name}

); } `); files.push(componentFile); if (style === 'css-module') { const cssFile = path.join(dir, `${name}.module.css`); assertNotExists(cssFile, overwrite); fs.writeFileSync(cssFile, `.root {\n}\n`); files.push(cssFile); } if (withTest) { const testFile = path.join(dir, `${name}.test.${ext}`); assertNotExists(testFile, overwrite); fs.writeFileSync(testFile, `import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { ${name} } from './${name}'; describe('${name}', () => { it('renders', () => { render(<${name} />); expect(screen.getByText('${name}')).toBeInTheDocument(); }); }); `); files.push(testFile); } return { kind: 'react', name, files, clientComponent }; } function generateVueSfc(frontendRoot, kind, name, directory, style, withTest, overwrite) { const typescript = fs.existsSync(path.join(frontendRoot, 'tsconfig.json')); const dir = resolveWorkspacePath(frontendRoot, directory ?? 'src/components'); const componentFile = path.join(dir, `${name}.vue`); const files = []; assertNotExists(componentFile, overwrite); fs.mkdirSync(dir, { recursive: true }); const langAttr = typescript ? ' lang="ts"' : ''; const rootClass = style === 'tailwind' ? ' class="p-4"' : style === 'none' ? '' : ' class="root"'; const styleBlock = style === 'none' ? '' : style === 'tailwind' ? '' : `\n\n`; fs.writeFileSync(componentFile, ` ${styleBlock}`); files.push(componentFile); if (withTest) { const testExt = typescript ? 'ts' : 'js'; const testFile = path.join(dir, `${name}.test.${testExt}`); assertNotExists(testFile, overwrite); fs.writeFileSync(testFile, `import { mount } from '@vue/test-utils'; import { describe, expect, it } from 'vitest'; import ${name} from './${name}.vue'; describe('${name}', () => { it('renders', () => { const wrapper = mount(${name}); expect(wrapper.text()).toContain('${name}'); }); }); `); files.push(testFile); } return { kind, name, files }; } function generatePlainComponent(root, name, directory, overwrite) { const dir = resolveWorkspacePath(root, directory ?? path.join('components', name)); fs.mkdirSync(dir, { recursive: true }); const htmlFile = path.join(dir, `${name}.html`); const cssFile = path.join(dir, `${name}.css`); const jsFile = path.join(dir, `${name}.js`); for (const file of [htmlFile, cssFile, jsFile]) assertNotExists(file, overwrite); fs.writeFileSync(htmlFile, `

${name}

`); fs.writeFileSync(cssFile, `.${name.toLowerCase()} {\n}\n`); fs.writeFileSync(jsFile, `// Behaviour for the ${name} component.\n`); return { kind: 'generic', name, files: [htmlFile, cssFile, jsFile], note: 'No React/Vue/Nuxt frontend was detected, so a plain HTML/CSS/JS snippet was generated instead.', }; } function assertNotExists(file, overwrite) { if (!overwrite && fs.existsSync(file)) { throw new Error(`"${file}" already exists. Pass overwrite: true to replace it.`); } } //# sourceMappingURL=generateComponent.js.map