#!/usr/bin/env node /** * 화면 관리 시스템 컴포넌트 자동 생성 CLI 스크립트 * 실제 컴포넌트 구조에 맞게 설계 */ const fs = require("fs"); const path = require("path"); // CLI 인자 파싱 const args = process.argv.slice(2); const componentName = args[0]; const displayName = args[1]; const description = args[2]; const category = args[3]; const webType = args[4] || "text"; // 입력값 검증 function validateInputs() { if (!componentName) { console.error("❌ 컴포넌트 이름을 제공해주세요."); showUsage(); process.exit(1); } if (!displayName) { console.error("❌ 표시 이름을 제공해주세요."); showUsage(); process.exit(1); } if (!description) { console.error("❌ 설명을 제공해주세요."); showUsage(); process.exit(1); } if (!category) { console.error("❌ 카테고리를 제공해주세요."); showUsage(); process.exit(1); } // 컴포넌트 이름 형식 검증 (kebab-case) if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(componentName)) { console.error("❌ 컴포넌트 이름은 kebab-case 형식이어야 합니다. (예: text-input, date-picker)"); process.exit(1); } // 카테고리 검증 const validCategories = ['input', 'display', 'action', 'layout', 'form', 'chart', 'media', 'navigation', 'feedback', 'utility', 'container', 'system', 'admin']; if (!validCategories.includes(category)) { console.error(`❌ 유효하지 않은 카테고리입니다. 사용 가능한 카테고리: ${validCategories.join(', ')}`); process.exit(1); } // 웹타입 검증 const validWebTypes = ['text', 'number', 'email', 'password', 'textarea', 'select', 'button', 'checkbox', 'radio', 'date', 'file']; if (webType && !validWebTypes.includes(webType)) { console.error(`❌ 유효하지 않은 웹타입입니다. 사용 가능한 웹타입: ${validWebTypes.join(', ')}`); process.exit(1); } } function showUsage() { console.log("\n📖 사용법:"); console.log("node scripts/create-component.js <컴포넌트이름> <표시이름> <설명> <카테고리> [웹타입]"); console.log("\n📋 예시:"); console.log("node scripts/create-component.js text-input '텍스트 입력' '기본 텍스트 입력 컴포넌트' input text"); console.log("node scripts/create-component.js action-button '액션 버튼' '사용자 액션 버튼' action button"); console.log("\n📂 카테고리: input, display, action, layout, form, chart, media, navigation, feedback, utility"); console.log("🎯 웹타입: text, number, email, password, textarea, select, button, checkbox, radio, date, file"); console.log("\n📚 자세한 사용법: docs/CLI_컴포넌트_생성_가이드.md"); } validateInputs(); // 옵션 파싱 const options = {}; args.slice(5).forEach((arg) => { if (arg.startsWith("--")) { const [key, value] = arg.substring(2).split("="); options[key] = value || true; } }); // 기본값 설정 const config = { name: componentName, displayName: displayName || componentName, description: description || `${displayName || componentName} 컴포넌트`, category: category || "display", webType: webType, author: options.author || "개발팀", size: options.size || "200x36", tags: options.tags ? options.tags.split(",") : [], }; // 이름 변환 함수들 function toCamelCase(str) { return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); } function toPascalCase(str) { return str.charAt(0).toUpperCase() + toCamelCase(str).slice(1); } function toKebabCase(str) { return str .toLowerCase() .replace(/[^a-z0-9]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } // 안전한 이름 생성 (하이픈 제거) function toSafeName(str) { return toCamelCase(str); } // 파싱된 이름들 const names = { kebab: toKebabCase(componentName), camel: toSafeName(componentName), pascal: toPascalCase(componentName), }; // 1. index.ts 파일 생성 function createIndexFile(componentDir, names, config, width, height) { const content = `"use client"; import React from "react"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { ComponentCategory } from "@/types/component"; import type { WebType } from "@/types/screen"; import { ${names.pascal}Wrapper } from "./${names.pascal}Component"; import { ${names.pascal}ConfigPanel } from "./${names.pascal}ConfigPanel"; import { ${names.pascal}Config } from "./types"; /** * ${names.pascal} 컴포넌트 정의 * ${config.description} */ export const ${names.pascal}Definition = createComponentDefinition({ id: "${names.kebab}", name: "${config.displayName}", nameEng: "${names.pascal} Component", description: "${config.description}", category: ComponentCategory.${config.category.toUpperCase()}, webType: "${config.webType}", component: ${names.pascal}Wrapper, defaultConfig: { ${getDefaultConfigByWebType(config.webType)} }, defaultSize: { width: ${width}, height: ${height} }, configPanel: ${names.pascal}ConfigPanel, icon: "${getIconByCategory(config.category)}", tags: [${config.tags.map((tag) => `"${tag}"`).join(", ")}], version: "1.0.0", author: "${config.author}", documentation: "https://docs.example.com/components/${names.kebab}", }); // 컴포넌트는 ${names.pascal}Renderer에서 자동 등록됩니다 // 타입 내보내기 export type { ${names.pascal}Config } from "./types"; `; fs.writeFileSync(path.join(componentDir, "index.ts"), content); console.log("✅ index.ts 생성 완료"); } // 2. Component 파일 생성 function createComponentFile(componentDir, names, config) { const content = `"use client"; import React from "react"; import { ComponentRendererProps } from "@/types/component"; import { ${names.pascal}Config } from "./types"; export interface ${names.pascal}ComponentProps extends ComponentRendererProps { config?: ${names.pascal}Config; } /** * ${names.pascal} 컴포넌트 * ${config.description} */ export const ${names.pascal}Component: React.FC<${names.pascal}ComponentProps> = ({ component, isDesignMode = false, isSelected = false, isInteractive = false, onClick, onDragStart, onDragEnd, config, className, style, formData, onFormDataChange, screenId, ...props }) => { // 컴포넌트 설정 const componentConfig = { ...config, ...component.config, } as ${names.pascal}Config; // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) const componentStyle: React.CSSProperties = { width: "100%", height: "100%", ...component.style, ...style, }; // 디자인 모드 스타일 if (isDesignMode) { componentStyle.border = "1px dashed #cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; } // 이벤트 핸들러 const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); }; // DOM에 전달하면 안 되는 React-specific props 필터링 const { selectedScreen, onZoneComponentDrop, onZoneClick, componentConfig: _componentConfig, component: _component, isSelected: _isSelected, onClick: _onClick, onDragStart: _onDragStart, onDragEnd: _onDragEnd, size: _size, position: _position, style: _style, ...domProps } = props; ${getComponentJSXByWebType(config.webType)} }; /** * ${names.pascal} 래퍼 컴포넌트 * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const ${names.pascal}Wrapper: React.FC<${names.pascal}ComponentProps> = (props) => { return <${names.pascal}Component {...props} />; }; `; fs.writeFileSync(path.join(componentDir, `${names.pascal}Component.tsx`), content); console.log(`✅ ${names.pascal}Component.tsx 생성 완료`); } // 3. Renderer 파일 생성 function createRendererFile(componentDir, names, config) { const content = `"use client"; import React from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { ${names.pascal}Definition } from "./index"; import { ${names.pascal}Component } from "./${names.pascal}Component"; /** * ${names.pascal} 렌더러 * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 */ export class ${names.pascal}Renderer extends AutoRegisteringComponentRenderer { static componentDefinition = ${names.pascal}Definition; render(): React.ReactElement { return <${names.pascal}Component {...this.props} renderer={this} />; } /** * 컴포넌트별 특화 메서드들 */ // ${config.webType} 타입 특화 속성 처리 protected get${names.pascal}Props() { const baseProps = this.getWebTypeProps(); // ${config.webType} 타입에 특화된 추가 속성들 return { ...baseProps, // 여기에 ${config.webType} 타입 특화 속성들 추가 }; } // 값 변경 처리 protected handleValueChange = (value: any) => { this.updateComponent({ value }); }; // 포커스 처리 protected handleFocus = () => { // 포커스 로직 }; // 블러 처리 protected handleBlur = () => { // 블러 로직 }; } // 자동 등록 실행 ${names.pascal}Renderer.registerSelf(); `; fs.writeFileSync(path.join(componentDir, `${names.pascal}Renderer.tsx`), content); console.log(`✅ ${names.pascal}Renderer.tsx 생성 완료`); } // 4. Config Panel 파일 생성 function createConfigPanelFile(componentDir, names, config) { const content = `"use client"; import React from "react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ${names.pascal}Config } from "./types"; export interface ${names.pascal}ConfigPanelProps { config: ${names.pascal}Config; onChange: (config: Partial<${names.pascal}Config>) => void; } /** * ${names.pascal} 설정 패널 * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 */ export const ${names.pascal}ConfigPanel: React.FC<${names.pascal}ConfigPanelProps> = ({ config, onChange, }) => { const handleChange = (key: keyof ${names.pascal}Config, value: any) => { onChange({ [key]: value }); }; return (
${config.description} 설정
${getConfigPanelJSXByWebType(config.webType)} {/* 공통 설정 */}
handleChange("disabled", checked)} />
handleChange("required", checked)} />
handleChange("readonly", checked)} />
); }; `; fs.writeFileSync(path.join(componentDir, `${names.pascal}ConfigPanel.tsx`), content); console.log(`✅ ${names.pascal}ConfigPanel.tsx 생성 완료`); } // 5. Types 파일 생성 function createTypesFile(componentDir, names, config) { const content = `"use client"; import { ComponentConfig } from "@/types/component"; /** * ${names.pascal} 컴포넌트 설정 타입 */ export interface ${names.pascal}Config extends ComponentConfig { ${getConfigTypesByWebType(config.webType)} // 공통 설정 disabled?: boolean; required?: boolean; readonly?: boolean; placeholder?: string; helperText?: string; // 스타일 관련 variant?: "default" | "outlined" | "filled"; size?: "sm" | "md" | "lg"; // 이벤트 관련 onChange?: (value: any) => void; onFocus?: () => void; onBlur?: () => void; onClick?: () => void; } /** * ${names.pascal} 컴포넌트 Props 타입 */ export interface ${names.pascal}Props { id?: string; name?: string; value?: any; config?: ${names.pascal}Config; className?: string; style?: React.CSSProperties; // 이벤트 핸들러 onChange?: (value: any) => void; onFocus?: () => void; onBlur?: () => void; onClick?: () => void; } `; fs.writeFileSync(path.join(componentDir, "types.ts"), content); console.log("✅ types.ts 생성 완료"); } // README 파일 생성 (config.ts는 제거) function createReadmeFile(componentDir, names, config, width, height) { const content = `# ${names.pascal} 컴포넌트 ${config.description} ## 개요 - **ID**: \`${names.kebab}\` - **카테고리**: ${config.category} - **웹타입**: ${config.webType} - **작성자**: ${config.author} - **버전**: 1.0.0 ## 특징 - ✅ 자동 등록 시스템 - ✅ 타입 안전성 - ✅ Hot Reload 지원 - ✅ 설정 패널 제공 - ✅ 반응형 디자인 ## 사용법 ### 기본 사용법 \`\`\`tsx import { ${names.pascal}Component } from "@/lib/registry/components/${names.kebab}"; <${names.pascal}Component component={{ id: "my-${names.kebab}", type: "widget", webType: "${config.webType}", position: { x: 100, y: 100, z: 1 }, size: { width: ${width}, height: ${height} }, config: { // 설정값들 } }} isDesignMode={false} /> \`\`\` ### 설정 옵션 | 속성 | 타입 | 기본값 | 설명 | |------|------|--------|------| ${getConfigDocumentationByWebType(config.webType)} | disabled | boolean | false | 비활성화 여부 | | required | boolean | false | 필수 입력 여부 | | readonly | boolean | false | 읽기 전용 여부 | ## 이벤트 - \`onChange\`: 값 변경 시 - \`onFocus\`: 포커스 시 - \`onBlur\`: 포커스 해제 시 - \`onClick\`: 클릭 시 ## 스타일링 컴포넌트는 다음과 같은 스타일 옵션을 제공합니다: - \`variant\`: "default" | "outlined" | "filled" - \`size\`: "sm" | "md" | "lg" ## 예시 \`\`\`tsx // 기본 예시 <${names.pascal}Component component={{ id: "sample-${names.kebab}", config: { placeholder: "입력하세요", required: true, variant: "outlined" } }} /> \`\`\` ## 개발자 정보 - **생성일**: ${new Date().toISOString().split("T")[0]} - **CLI 명령어**: \`node scripts/create-component.js ${componentName} "${config.displayName}" "${config.description}" ${config.category} ${config.webType}\` - **경로**: \`lib/registry/components/${names.kebab}/\` ## 관련 문서 - [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md) - [개발자 문서](https://docs.example.com/components/${names.kebab}) `; fs.writeFileSync(path.join(componentDir, "README.md"), content); console.log("✅ README.md 생성 완료"); } // index.ts 파일에 import 자동 추가 함수 function addToRegistryIndex(names) { const indexFilePath = path.join(__dirname, "../lib/registry/components/index.ts"); try { // 기존 파일 읽기 const existingContent = fs.readFileSync(indexFilePath, "utf8"); // 새로운 import 구문 const newImport = `import "./${names.kebab}/${names.pascal}Renderer";`; // 이미 존재하는지 확인 if (existingContent.includes(newImport)) { console.log("⚠️ import가 이미 존재합니다."); return; } // 기존 import들 찾기 (마지막 import 이후에 추가) const lines = existingContent.split('\n'); const lastImportIndex = lines.findLastIndex(line => line.trim().startsWith('import ') && line.includes('Renderer')); if (lastImportIndex !== -1) { // 마지막 import 다음에 새로운 import 추가 lines.splice(lastImportIndex + 1, 0, newImport); } else { // import가 없으면 기존 import 구역 끝에 추가 const importSectionEnd = lines.findIndex(line => line.trim() === '' && lines.indexOf(line) > 10); if (importSectionEnd !== -1) { lines.splice(importSectionEnd, 0, newImport); } else { // 적절한 위치를 찾지 못했으면 끝에 추가 lines.push(newImport); } } // 파일에 다시 쓰기 const newContent = lines.join('\n'); fs.writeFileSync(indexFilePath, newContent); console.log("✅ index.ts에 import 자동 추가 완료"); } catch (error) { console.error("⚠️ index.ts 업데이트 중 오류:", error.message); console.log(`📝 수동으로 다음을 추가해주세요:`); console.log(` ${newImport}`); } } // 헬퍼 함수들 function getDefaultConfigByWebType(webType) { switch (webType) { case "text": return `placeholder: "텍스트를 입력하세요", maxLength: 255,`; case "number": return `min: 0, max: 999999, step: 1,`; case "email": return `placeholder: "이메일을 입력하세요",`; case "password": return `placeholder: "비밀번호를 입력하세요", minLength: 8,`; case "textarea": return `placeholder: "내용을 입력하세요", rows: 3, maxLength: 1000,`; case "select": return `options: [], placeholder: "선택하세요",`; case "button": return `text: "버튼", actionType: "button", variant: "primary",`; default: return `placeholder: "입력하세요",`; } } function getIconByCategory(category) { const icons = { ui: "Square", input: "Edit", display: "Eye", action: "MousePointer", layout: "Layout", chart: "BarChart", form: "FormInput", media: "Image", navigation: "Menu", feedback: "Bell", utility: "Settings", container: "Package", system: "Cpu", admin: "Shield", }; return icons[category] || "Component"; } function getComponentJSXByWebType(webType) { switch (webType) { case "text": case "email": case "password": case "number": return ` return (
{/* 라벨 렌더링 */} {component.label && ( )} { if (domProps.onChange) { domProps.onChange(e.target.value); } }} />
);`; case "textarea": return ` return (
{/* 라벨 렌더링 */} {component.label && ( )}