init project
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
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 (
|
||||
<div${rootClass}>
|
||||
<p>${name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`);
|
||||
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<style scoped>\n.root {\n}\n</style>\n`;
|
||||
fs.writeFileSync(componentFile, `<script setup${langAttr}>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div${rootClass}>
|
||||
<p>${name}</p>
|
||||
</div>
|
||||
</template>
|
||||
${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, `<div class="${name.toLowerCase()}">
|
||||
<p>${name}</p>
|
||||
</div>
|
||||
`);
|
||||
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
|
||||
Reference in New Issue
Block a user