525 lines
14 KiB
JavaScript
525 lines
14 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
|
||
|
|
const fs = require("fs");
|
||
|
|
const path = require("path");
|
||
|
|
const { execSync } = require("child_process");
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 레이아웃 스캐폴딩 CLI 도구
|
||
|
|
*
|
||
|
|
* 사용법:
|
||
|
|
* npm run create-layout <layoutName> [options]
|
||
|
|
*
|
||
|
|
* 예시:
|
||
|
|
* npm run create-layout accordion
|
||
|
|
* npm run create-layout sidebar --category=navigation --zones=3
|
||
|
|
*/
|
||
|
|
|
||
|
|
// 명령행 인자 파싱
|
||
|
|
const args = process.argv.slice(2);
|
||
|
|
const layoutName = args[0];
|
||
|
|
|
||
|
|
if (!layoutName) {
|
||
|
|
console.error("❌ 레이아웃 이름이 필요합니다.");
|
||
|
|
console.log("사용법: npm run create-layout <layoutName> [options]");
|
||
|
|
console.log("예시: npm run create-layout accordion --category=navigation");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 옵션 파싱
|
||
|
|
const options = {};
|
||
|
|
args.slice(1).forEach((arg) => {
|
||
|
|
if (arg.startsWith("--")) {
|
||
|
|
const [key, value] = arg.slice(2).split("=");
|
||
|
|
options[key] = value || true;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 하이픈을 카멜케이스로 변환
|
||
|
|
*/
|
||
|
|
function toCamelCase(str) {
|
||
|
|
return str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 하이픈을 파스칼케이스로 변환
|
||
|
|
*/
|
||
|
|
function toPascalCase(str) {
|
||
|
|
const camelCase = toCamelCase(str);
|
||
|
|
return camelCase.charAt(0).toUpperCase() + camelCase.slice(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 안전한 식별자명 생성 (하이픈 제거)
|
||
|
|
*/
|
||
|
|
function toSafeId(str) {
|
||
|
|
return str.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||
|
|
}
|
||
|
|
|
||
|
|
// 안전한 이름들 생성
|
||
|
|
const safeLayoutName = toCamelCase(layoutName);
|
||
|
|
const pascalLayoutName = toPascalCase(layoutName);
|
||
|
|
const safeId = layoutName.toLowerCase(); // kebab-case는 id로 유지
|
||
|
|
|
||
|
|
// 기본 옵션
|
||
|
|
const config = {
|
||
|
|
name: safeLayoutName,
|
||
|
|
id: safeId,
|
||
|
|
className: pascalLayoutName,
|
||
|
|
category: options.category || "basic",
|
||
|
|
zones: parseInt(options.zones) || 2,
|
||
|
|
description: options.description || `${safeLayoutName} 레이아웃입니다.`,
|
||
|
|
author: options.author || "Developer",
|
||
|
|
...options,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 검증
|
||
|
|
if (!/^[a-z][a-z0-9-]*$/.test(config.id)) {
|
||
|
|
console.error("❌ 레이아웃 이름은 소문자로 시작하고 소문자, 숫자, 하이픈만 포함해야 합니다.");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const layoutDir = path.join(__dirname, "../lib/registry/layouts", config.id);
|
||
|
|
|
||
|
|
// 디렉토리 존재 확인
|
||
|
|
if (fs.existsSync(layoutDir)) {
|
||
|
|
console.error(`❌ 레이아웃 디렉토리가 이미 존재합니다: ${layoutDir}`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log("🚀 새 레이아웃 생성 중...");
|
||
|
|
console.log(`📁 이름: ${config.name}`);
|
||
|
|
console.log(`🔖 ID: ${config.id}`);
|
||
|
|
console.log(`📂 카테고리: ${config.category}`);
|
||
|
|
console.log(`🎯 존 개수: ${config.zones}`);
|
||
|
|
|
||
|
|
// 디렉토리 생성
|
||
|
|
fs.mkdirSync(layoutDir, { recursive: true });
|
||
|
|
|
||
|
|
// 템플릿 파일들 생성
|
||
|
|
createIndexFile();
|
||
|
|
createLayoutComponent();
|
||
|
|
createLayoutRenderer();
|
||
|
|
createConfigFile();
|
||
|
|
createTypesFile();
|
||
|
|
createReadme();
|
||
|
|
|
||
|
|
// package.json 스크립트 업데이트
|
||
|
|
updatePackageScripts();
|
||
|
|
|
||
|
|
console.log("");
|
||
|
|
console.log("✅ 레이아웃 생성 완료!");
|
||
|
|
console.log("");
|
||
|
|
console.log("📝 다음 단계:");
|
||
|
|
console.log(`1. ${layoutDir}/${config.className}Layout.tsx 에서 비즈니스 로직 구현`);
|
||
|
|
console.log("2. 파일을 저장하면 자동으로 화면편집기에서 사용 가능");
|
||
|
|
console.log("3. 필요에 따라 config.ts에서 기본 설정 조정");
|
||
|
|
console.log("");
|
||
|
|
console.log("🔧 개발 팁:");
|
||
|
|
console.log("- 브라우저 개발자 도구에서 window.__LAYOUT_REGISTRY__.list() 로 등록 확인");
|
||
|
|
console.log("- Hot Reload 지원으로 파일 수정 시 자동 업데이트");
|
||
|
|
|
||
|
|
/**
|
||
|
|
* index.ts 파일 생성
|
||
|
|
*/
|
||
|
|
function createIndexFile() {
|
||
|
|
const content = `"use client";
|
||
|
|
|
||
|
|
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
|
||
|
|
import { ${config.className}Layout } from "./${config.className}Layout";
|
||
|
|
import { ${config.className}LayoutRenderer } from "./${config.className}LayoutRenderer";
|
||
|
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||
|
|
import React from "react";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ${config.name} 래퍼 컴포넌트 (DynamicLayoutRenderer용)
|
||
|
|
*/
|
||
|
|
const ${config.className}LayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
|
||
|
|
const renderer = new ${config.className}LayoutRenderer(props);
|
||
|
|
return renderer.render();
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ${config.name} 레이아웃 정의
|
||
|
|
*/
|
||
|
|
export const ${config.className}LayoutDefinition = createLayoutDefinition({
|
||
|
|
id: "${config.id}",
|
||
|
|
name: "${config.name}",
|
||
|
|
nameEng: "${config.className} Layout",
|
||
|
|
description: "${config.description}",
|
||
|
|
category: "${config.category}",
|
||
|
|
icon: "${config.icon || config.id}",
|
||
|
|
component: ${config.className}LayoutWrapper,
|
||
|
|
defaultConfig: {
|
||
|
|
${toCamelCase(config.id)}: {
|
||
|
|
// TODO: 레이아웃별 설정 정의
|
||
|
|
},
|
||
|
|
},
|
||
|
|
defaultZones: [${generateDefaultZones()}
|
||
|
|
],
|
||
|
|
tags: ["${config.id}", "${config.category}", "layout"],
|
||
|
|
version: "1.0.0",
|
||
|
|
author: "${config.author}",
|
||
|
|
documentation: "${config.description}",
|
||
|
|
});
|
||
|
|
|
||
|
|
// 자동 등록을 위한 export
|
||
|
|
export { ${config.className}LayoutRenderer } from "./${config.className}LayoutRenderer";
|
||
|
|
`;
|
||
|
|
|
||
|
|
fs.writeFileSync(path.join(layoutDir, "index.ts"), content);
|
||
|
|
console.log("✅ index.ts 생성됨");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 레이아웃 컴포넌트 파일 생성
|
||
|
|
*/
|
||
|
|
function createLayoutComponent() {
|
||
|
|
const content = `"use client";
|
||
|
|
|
||
|
|
import React from "react";
|
||
|
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ${config.name} 컴포넌트
|
||
|
|
*/
|
||
|
|
export interface ${config.className}LayoutProps extends LayoutRendererProps {
|
||
|
|
renderer: any; // ${config.className}LayoutRenderer 타입
|
||
|
|
}
|
||
|
|
|
||
|
|
export const ${config.className}Layout: React.FC<${config.className}LayoutProps> = ({
|
||
|
|
layout,
|
||
|
|
isDesignMode = false,
|
||
|
|
isSelected = false,
|
||
|
|
onClick,
|
||
|
|
className = "",
|
||
|
|
renderer,
|
||
|
|
...props
|
||
|
|
}) => {
|
||
|
|
if (!layout.layoutConfig.${toCamelCase(config.id)}) {
|
||
|
|
return (
|
||
|
|
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
|
||
|
|
<div className="text-center text-red-600">
|
||
|
|
<div className="font-medium">${config.name} 설정이 없습니다.</div>
|
||
|
|
<div className="text-sm mt-1">layoutConfig.${toCamelCase(config.id)}가 필요합니다.</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const ${config.name}Config = layout.layoutConfig.${toCamelCase(config.id)};
|
||
|
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||
|
|
|
||
|
|
// ${config.name} 컨테이너 스타일
|
||
|
|
const ${config.name}Style: React.CSSProperties = {
|
||
|
|
...containerStyle,
|
||
|
|
// TODO: 레이아웃 전용 스타일 정의
|
||
|
|
height: "100%",
|
||
|
|
width: "100%",
|
||
|
|
};
|
||
|
|
|
||
|
|
// 디자인 모드 스타일
|
||
|
|
if (isDesignMode) {
|
||
|
|
${config.name}Style.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||
|
|
${config.name}Style.borderRadius = "8px";
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={\`${config.id}-layout \${isDesignMode ? "design-mode" : ""} \${className}\`}
|
||
|
|
style={${config.name}Style}
|
||
|
|
onClick={onClick}
|
||
|
|
draggable={isDesignMode}
|
||
|
|
onDragStart={props.onDragStart}
|
||
|
|
onDragEnd={props.onDragEnd}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{layout.zones.map((zone: any) => {
|
||
|
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||
|
|
|
||
|
|
// TODO: 존별 스타일 정의
|
||
|
|
const zoneStyle: React.CSSProperties = {
|
||
|
|
// 레이아웃별 존 스타일 구현
|
||
|
|
};
|
||
|
|
|
||
|
|
return renderer.renderZone(zone, zoneChildren, {
|
||
|
|
style: zoneStyle,
|
||
|
|
className: "${config.id}-zone",
|
||
|
|
});
|
||
|
|
})}
|
||
|
|
|
||
|
|
{/* 디자인 모드에서 빈 영역 표시 */}
|
||
|
|
{isDesignMode && layout.zones.length === 0 && (
|
||
|
|
<div
|
||
|
|
className="empty-${config.id}-container"
|
||
|
|
style={{
|
||
|
|
flex: 1,
|
||
|
|
border: "2px dashed #cbd5e1",
|
||
|
|
borderRadius: "8px",
|
||
|
|
backgroundColor: "rgba(148, 163, 184, 0.05)",
|
||
|
|
display: "flex",
|
||
|
|
alignItems: "center",
|
||
|
|
justifyContent: "center",
|
||
|
|
fontSize: "14px",
|
||
|
|
color: "#64748b",
|
||
|
|
minHeight: "100px",
|
||
|
|
padding: "20px",
|
||
|
|
textAlign: "center",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
${config.name}에 존을 추가하세요
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
`;
|
||
|
|
|
||
|
|
fs.writeFileSync(path.join(layoutDir, `${config.className}Layout.tsx`), content);
|
||
|
|
console.log(`✅ ${config.className}Layout.tsx 생성됨`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 레이아웃 렌더러 파일 생성
|
||
|
|
*/
|
||
|
|
function createLayoutRenderer() {
|
||
|
|
const content = `"use client";
|
||
|
|
|
||
|
|
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
|
||
|
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||
|
|
import { ${config.className}LayoutDefinition } from "./index";
|
||
|
|
import { ${config.className}Layout } from "./${config.className}Layout";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ${config.name} 렌더러 (새 구조)
|
||
|
|
*/
|
||
|
|
export class ${config.className}LayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||
|
|
/**
|
||
|
|
* 레이아웃 정의 (자동 등록용)
|
||
|
|
*/
|
||
|
|
static readonly layoutDefinition = ${config.className}LayoutDefinition;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 클래스 로드 시 자동 등록 실행
|
||
|
|
*/
|
||
|
|
static {
|
||
|
|
this.registerSelf();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 렌더링 실행
|
||
|
|
*/
|
||
|
|
render(): React.ReactElement {
|
||
|
|
return <${config.className}Layout {...this.props} renderer={this} />;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* React 함수 컴포넌트로 래핑 (외부 사용용)
|
||
|
|
*/
|
||
|
|
export const ${config.className}LayoutComponent: React.FC<LayoutRendererProps> = (props) => {
|
||
|
|
const renderer = new ${config.className}LayoutRenderer(props);
|
||
|
|
return renderer.render();
|
||
|
|
};
|
||
|
|
|
||
|
|
// 개발 모드에서 Hot Reload 지원
|
||
|
|
if (process.env.NODE_ENV === 'development') {
|
||
|
|
// HMR API 등록
|
||
|
|
if ((module as any).hot) {
|
||
|
|
(module as any).hot.accept();
|
||
|
|
(module as any).hot.dispose(() => {
|
||
|
|
${config.className}LayoutRenderer.unregisterSelf();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
|
||
|
|
fs.writeFileSync(path.join(layoutDir, `${config.className}LayoutRenderer.tsx`), content);
|
||
|
|
console.log(`✅ ${config.className}LayoutRenderer.tsx 생성됨`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 설정 파일 생성
|
||
|
|
*/
|
||
|
|
function createConfigFile() {
|
||
|
|
const content = `/**
|
||
|
|
* ${config.name} 기본 설정
|
||
|
|
*/
|
||
|
|
export const ${config.className}LayoutConfig = {
|
||
|
|
defaultConfig: {
|
||
|
|
${config.id}: {
|
||
|
|
// TODO: 레이아웃 전용 설정 정의
|
||
|
|
// 예시:
|
||
|
|
// spacing: 16,
|
||
|
|
// orientation: "vertical",
|
||
|
|
// allowResize: true,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
|
||
|
|
defaultZones: [${generateDefaultZones()}
|
||
|
|
],
|
||
|
|
|
||
|
|
// 설정 스키마 (검증용)
|
||
|
|
configSchema: {
|
||
|
|
type: "object",
|
||
|
|
properties: {
|
||
|
|
${config.id}: {
|
||
|
|
type: "object",
|
||
|
|
properties: {
|
||
|
|
// TODO: 설정 스키마 정의
|
||
|
|
},
|
||
|
|
additionalProperties: false,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
required: ["${config.id}"],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
`;
|
||
|
|
|
||
|
|
fs.writeFileSync(path.join(layoutDir, "config.ts"), content);
|
||
|
|
console.log("✅ config.ts 생성됨");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 타입 정의 파일 생성
|
||
|
|
*/
|
||
|
|
function createTypesFile() {
|
||
|
|
const content = `import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ${config.name} 설정 타입
|
||
|
|
*/
|
||
|
|
export interface ${config.className}Config {
|
||
|
|
// TODO: 레이아웃 전용 설정 타입 정의
|
||
|
|
// 예시:
|
||
|
|
// spacing?: number;
|
||
|
|
// orientation?: "vertical" | "horizontal";
|
||
|
|
// allowResize?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ${config.name} Props 타입
|
||
|
|
*/
|
||
|
|
export interface ${config.className}LayoutProps extends LayoutRendererProps {
|
||
|
|
renderer: any; // ${config.className}LayoutRenderer 타입
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ${config.name} 존 타입
|
||
|
|
*/
|
||
|
|
export interface ${config.className}Zone {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
// TODO: 존별 전용 속성 정의
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
|
||
|
|
fs.writeFileSync(path.join(layoutDir, "types.ts"), content);
|
||
|
|
console.log("✅ types.ts 생성됨");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* README 파일 생성
|
||
|
|
*/
|
||
|
|
function createReadme() {
|
||
|
|
const content = `# ${config.name}
|
||
|
|
|
||
|
|
${config.description}
|
||
|
|
|
||
|
|
## 사용법
|
||
|
|
|
||
|
|
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
|
||
|
|
|
||
|
|
## 구성
|
||
|
|
|
||
|
|
- \`${config.className}Layout.tsx\`: 메인 레이아웃 컴포넌트
|
||
|
|
- \`${config.className}LayoutRenderer.tsx\`: 렌더러 (자동 등록)
|
||
|
|
- \`config.ts\`: 기본 설정
|
||
|
|
- \`types.ts\`: 타입 정의
|
||
|
|
- \`index.ts\`: 진입점
|
||
|
|
|
||
|
|
## 개발
|
||
|
|
|
||
|
|
1. \`${config.className}Layout.tsx\`에서 레이아웃 로직 구현
|
||
|
|
2. \`config.ts\`에서 기본 설정 조정
|
||
|
|
3. \`types.ts\`에서 타입 정의 추가
|
||
|
|
|
||
|
|
## 설정
|
||
|
|
|
||
|
|
\`\`\`typescript
|
||
|
|
{
|
||
|
|
${config.id}: {
|
||
|
|
// TODO: 설정 옵션 문서화
|
||
|
|
}
|
||
|
|
}
|
||
|
|
\`\`\`
|
||
|
|
|
||
|
|
## 존 구성
|
||
|
|
|
||
|
|
${generateZoneDocumentation()}
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
생성일: ${new Date().toLocaleDateString()}
|
||
|
|
버전: 1.0.0
|
||
|
|
작성자: ${config.author}
|
||
|
|
`;
|
||
|
|
|
||
|
|
fs.writeFileSync(path.join(layoutDir, "README.md"), content);
|
||
|
|
console.log("✅ README.md 생성됨");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 기본 존 생성
|
||
|
|
*/
|
||
|
|
function generateDefaultZones() {
|
||
|
|
const zones = [];
|
||
|
|
for (let i = 1; i <= config.zones; i++) {
|
||
|
|
zones.push(`
|
||
|
|
{
|
||
|
|
id: "zone${i}",
|
||
|
|
name: "존 ${i}",
|
||
|
|
position: {},
|
||
|
|
size: { width: "100%", height: "100%" },
|
||
|
|
}`);
|
||
|
|
}
|
||
|
|
return zones.join(",");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 존 문서화
|
||
|
|
*/
|
||
|
|
function generateZoneDocumentation() {
|
||
|
|
const docs = [];
|
||
|
|
for (let i = 1; i <= config.zones; i++) {
|
||
|
|
docs.push(`- **존 ${i}** (\`zone${i}\`): 기본 영역`);
|
||
|
|
}
|
||
|
|
return docs.join("\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* package.json 스크립트 업데이트
|
||
|
|
*/
|
||
|
|
function updatePackageScripts() {
|
||
|
|
const packagePath = path.join(__dirname, "../package.json");
|
||
|
|
|
||
|
|
if (fs.existsSync(packagePath)) {
|
||
|
|
try {
|
||
|
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||
|
|
|
||
|
|
if (!packageJson.scripts) {
|
||
|
|
packageJson.scripts = {};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!packageJson.scripts["create-layout"]) {
|
||
|
|
packageJson.scripts["create-layout"] = "node scripts/create-layout.js";
|
||
|
|
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
|
||
|
|
console.log("✅ package.json 스크립트 추가됨");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.warn("⚠️ package.json 업데이트 실패:", error.message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|