ERP-node/frontend/scripts/create-layout.js

525 lines
14 KiB
JavaScript
Raw Normal View History

2025-09-10 18:36:28 +09:00
#!/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);
}
}
}