create-mendix-widget-gleam 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-mendix-widget-gleam.mjs +4 -0
- package/package.json +24 -0
- package/src/index.mjs +291 -0
- package/src/naming.mjs +56 -0
- package/src/pm.mjs +26 -0
- package/src/prompts.mjs +116 -0
- package/src/scaffold.mjs +90 -0
- package/template/.gitattributes +21 -0
- package/template/.prettierignore +1 -0
- package/template/LICENSE +15 -0
- package/template/_gitignore +48 -0
- package/template/docs/gleam_language_tour.md +1884 -0
- package/template/gleam.toml +10 -0
- package/template/manifest.toml +9 -0
- package/template/package.json +35 -0
- package/template/prettier.config.js +6 -0
- package/template/src/__WidgetName__.editorConfig.js +5 -0
- package/template/src/__WidgetName__.js +6 -0
- package/template/src/__WidgetName__.xml +17 -0
- package/template/src/package.xml +11 -0
- package/template/src/scripts/build.gleam +8 -0
- package/template/src/scripts/cmd.gleam +4 -0
- package/template/src/scripts/cmd_ffi.mjs +6 -0
- package/template/src/scripts/dev.gleam +8 -0
- package/template/src/scripts/install.gleam +8 -0
- package/template/src/scripts/lint.gleam +7 -0
- package/template/src/scripts/lint_fix.gleam +7 -0
- package/template/src/scripts/release.gleam +8 -0
- package/template/src/scripts/start.gleam +8 -0
- package/template/src/ui/__WidgetName__.css +6 -0
- package/template/src/widget/__widget_name__.gleam +20 -0
- package/template/src/widget/__widget_name___ffi.mjs +13 -0
- package/template/src/widget/editor_config.gleam +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-mendix-widget-gleam",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Scaffold a Mendix Pluggable Widget powered by Gleam",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"bin": "bin/create-mendix-widget-gleam.mjs",
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"src/",
|
|
11
|
+
"template/"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mendix",
|
|
18
|
+
"gleam",
|
|
19
|
+
"widget",
|
|
20
|
+
"pluggable-widget",
|
|
21
|
+
"scaffold",
|
|
22
|
+
"create"
|
|
23
|
+
]
|
|
24
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-mendix-widget-gleam 메인 오케스트레이션
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve, dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { mkdir } from "node:fs/promises";
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { collectOptions } from "./prompts.mjs";
|
|
10
|
+
import { generateNames } from "./naming.mjs";
|
|
11
|
+
import { getPmConfig } from "./pm.mjs";
|
|
12
|
+
import { scaffold } from "./scaffold.mjs";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const TEMPLATE_DIR = resolve(__dirname, "..", "template");
|
|
16
|
+
|
|
17
|
+
const BOLD = "\x1b[1m";
|
|
18
|
+
const RESET = "\x1b[0m";
|
|
19
|
+
const GREEN = "\x1b[32m";
|
|
20
|
+
const CYAN = "\x1b[36m";
|
|
21
|
+
const DIM = "\x1b[2m";
|
|
22
|
+
const YELLOW = "\x1b[33m";
|
|
23
|
+
|
|
24
|
+
const VERSION = "1.0.0";
|
|
25
|
+
|
|
26
|
+
const HELP = `
|
|
27
|
+
${BOLD}create-mendix-widget-gleam${RESET} — Gleam + Mendix Pluggable Widget 프로젝트 생성
|
|
28
|
+
|
|
29
|
+
${BOLD}사용법:${RESET}
|
|
30
|
+
npx create-mendix-widget-gleam [project-name]
|
|
31
|
+
|
|
32
|
+
${BOLD}옵션:${RESET}
|
|
33
|
+
--help, -h 도움말 표시
|
|
34
|
+
--version, -v 버전 표시
|
|
35
|
+
|
|
36
|
+
${BOLD}예시:${RESET}
|
|
37
|
+
npx create-mendix-widget-gleam my-cool-widget
|
|
38
|
+
npx create-mendix-widget-gleam MyCoolWidget
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
export async function main(args) {
|
|
42
|
+
// 플래그 처리
|
|
43
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
44
|
+
console.log(HELP);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
48
|
+
console.log(VERSION);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(
|
|
53
|
+
`\n${BOLD}${CYAN}create-mendix-widget-gleam${RESET} ${DIM}v${VERSION}${RESET}\n`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// CLI 인자에서 프로젝트명 추출 (플래그 제외)
|
|
57
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
58
|
+
const cliProjectName = positional[0] || null;
|
|
59
|
+
|
|
60
|
+
// 프롬프트로 설정 수집
|
|
61
|
+
const { projectName, pm } = await collectOptions(cliProjectName);
|
|
62
|
+
|
|
63
|
+
// 이름 변환
|
|
64
|
+
const names = generateNames(projectName);
|
|
65
|
+
if (!names) {
|
|
66
|
+
console.error(`${YELLOW}오류: 유효하지 않은 프로젝트 이름입니다.${RESET}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pmConfig = getPmConfig(pm);
|
|
71
|
+
const targetDir = resolve(process.cwd(), names.kebabCase);
|
|
72
|
+
|
|
73
|
+
// 요약 표시
|
|
74
|
+
console.log(`\n${BOLD}프로젝트 설정:${RESET}`);
|
|
75
|
+
console.log(` 디렉토리: ${CYAN}${names.kebabCase}/${RESET}`);
|
|
76
|
+
console.log(` 위젯 이름: ${names.pascalCase}`);
|
|
77
|
+
console.log(` Gleam 모듈: ${names.snakeCase}`);
|
|
78
|
+
console.log(` 패키지 매니저: ${pm}`);
|
|
79
|
+
console.log();
|
|
80
|
+
|
|
81
|
+
// 디렉토리 생성
|
|
82
|
+
await mkdir(targetDir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
// 템플릿 스케폴딩
|
|
85
|
+
console.log(`${DIM}파일 생성 중...${RESET}`);
|
|
86
|
+
const created = await scaffold(TEMPLATE_DIR, targetDir, names, pmConfig);
|
|
87
|
+
console.log(`${GREEN}✓${RESET} ${created.length}개 파일 생성 완료`);
|
|
88
|
+
|
|
89
|
+
// CLAUDE.md 생성
|
|
90
|
+
await generateClaudeMd(targetDir, names, pm, pmConfig);
|
|
91
|
+
console.log(`${GREEN}✓${RESET} CLAUDE.md 생성 완료`);
|
|
92
|
+
|
|
93
|
+
// README.md 생성
|
|
94
|
+
await generateReadme(targetDir, names, pm, pmConfig);
|
|
95
|
+
console.log(`${GREEN}✓${RESET} README.md 생성 완료`);
|
|
96
|
+
|
|
97
|
+
// git init
|
|
98
|
+
try {
|
|
99
|
+
execSync("git init", { cwd: targetDir, stdio: "ignore" });
|
|
100
|
+
console.log(`${GREEN}✓${RESET} git 저장소 초기화 완료`);
|
|
101
|
+
} catch {
|
|
102
|
+
// git이 없어도 계속 진행
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 완료 메시지
|
|
106
|
+
console.log(`
|
|
107
|
+
${GREEN}${BOLD}프로젝트가 생성되었습니다!${RESET}
|
|
108
|
+
|
|
109
|
+
${BOLD}다음 단계:${RESET}
|
|
110
|
+
|
|
111
|
+
${CYAN}cd ${names.kebabCase}${RESET}
|
|
112
|
+
${CYAN}gleam run -m scripts/install${RESET} ${DIM}# 의존성 설치${RESET}
|
|
113
|
+
${CYAN}gleam run -m scripts/dev${RESET} ${DIM}# 개발 서버 시작${RESET}
|
|
114
|
+
${CYAN}gleam run -m scripts/build${RESET} ${DIM}# 프로덕션 빌드${RESET}
|
|
115
|
+
`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** CLAUDE.md 생성 */
|
|
119
|
+
async function generateClaudeMd(targetDir, names, pm, pmConfig) {
|
|
120
|
+
const { writeFile } = await import("node:fs/promises");
|
|
121
|
+
|
|
122
|
+
const content = `# ${names.pascalCase}
|
|
123
|
+
|
|
124
|
+
Gleam 언어로 Mendix Pluggable Widget을 개발하여 "Hello World"를 화면에 렌더링하는 프로젝트.
|
|
125
|
+
|
|
126
|
+
## Goal
|
|
127
|
+
|
|
128
|
+
**JSX를 사용하지 않고, 오직 Gleam으로만** 위젯을 작성한다. Gleam 코드를 JavaScript로 컴파일하고, 컴파일된 JS가 곧 Mendix Pluggable Widget의 진입점이 된다.
|
|
129
|
+
|
|
130
|
+
## Tech Stack
|
|
131
|
+
|
|
132
|
+
- **Gleam** → JavaScript 컴파일 (target: javascript)
|
|
133
|
+
- **Gleam FFI** (\`@external\` 어노테이션 + \`.ffi.mjs\` 파일) — React API를 Gleam에서 직접 호출
|
|
134
|
+
- **Mendix Pluggable Widget** (React 19)
|
|
135
|
+
- **Package Manager**: ${pm} (npm 의존성은 \`gleam run -m scripts/install\`로 설치)
|
|
136
|
+
- **Build**: \`@mendix/pluggable-widgets-tools\` (Rollup 기반)
|
|
137
|
+
|
|
138
|
+
## Architecture
|
|
139
|
+
|
|
140
|
+
\`\`\`
|
|
141
|
+
src/
|
|
142
|
+
widget/ # 핵심 Gleam 코드 (개발자가 작업하는 곳)
|
|
143
|
+
${names.snakeCase}.gleam # 위젯 메인 모듈
|
|
144
|
+
${names.snakeCase}_ffi.mjs # React FFI 어댑터
|
|
145
|
+
editor_config.gleam # Studio Pro 속성 패널 설정
|
|
146
|
+
scripts/ # 빌드/개발 스크립트 (gleam run -m으로 실행)
|
|
147
|
+
cmd.gleam # 셸 명령어 실행 유틸리티
|
|
148
|
+
cmd_ffi.mjs # Node.js child_process FFI
|
|
149
|
+
install.gleam # npm 의존성 설치
|
|
150
|
+
build.gleam # 프로덕션 빌드
|
|
151
|
+
dev.gleam # 개발 서버
|
|
152
|
+
start.gleam # Mendix 테스트 프로젝트 연동
|
|
153
|
+
release.gleam # 릴리즈 빌드
|
|
154
|
+
lint.gleam # ESLint 실행
|
|
155
|
+
lint_fix.gleam # ESLint 자동 수정
|
|
156
|
+
${names.pascalCase}.js # 브릿지 진입점
|
|
157
|
+
${names.pascalCase}.editorConfig.js # 브릿지 (editorConfig)
|
|
158
|
+
${names.pascalCase}.xml # 위젯 속성 정의
|
|
159
|
+
package.xml # Mendix 패키지 매니페스트
|
|
160
|
+
ui/
|
|
161
|
+
${names.pascalCase}.css # 위젯 스타일시트
|
|
162
|
+
gleam.toml # Gleam 프로젝트 설정
|
|
163
|
+
docs/
|
|
164
|
+
gleam_language_tour.md # Gleam 언어 레퍼런스
|
|
165
|
+
\`\`\`
|
|
166
|
+
|
|
167
|
+
## Build Pipeline
|
|
168
|
+
|
|
169
|
+
\`\`\`
|
|
170
|
+
[src/widget/${names.snakeCase}.gleam] + [src/widget/${names.snakeCase}_ffi.mjs]
|
|
171
|
+
↓ gleam run -m scripts/build
|
|
172
|
+
[build/dev/javascript/${names.snakeCase}/widget/${names.snakeCase}.mjs]
|
|
173
|
+
↓ src/${names.pascalCase}.js (브릿지)가 import
|
|
174
|
+
↓ Rollup (pluggable-widgets-tools build:web)
|
|
175
|
+
[dist/1.0.0/mendix.${names.lowerCase}.${names.pascalCase}.mpk]
|
|
176
|
+
\`\`\`
|
|
177
|
+
|
|
178
|
+
## Commands
|
|
179
|
+
|
|
180
|
+
\`\`\`bash
|
|
181
|
+
gleam run -m scripts/install # 의존성 설치
|
|
182
|
+
gleam run -m scripts/build # 위젯 프로덕션 빌드 (.mpk 생성)
|
|
183
|
+
gleam run -m scripts/dev # 개발 서버 (HMR, port 3000)
|
|
184
|
+
gleam run -m scripts/start # Mendix 테스트 프로젝트와 연동 개발
|
|
185
|
+
gleam run -m scripts/lint # ESLint 실행
|
|
186
|
+
gleam run -m scripts/lint_fix # ESLint 자동 수정
|
|
187
|
+
gleam run -m scripts/release # 릴리즈 빌드
|
|
188
|
+
gleam build --target javascript # Gleam → JS 컴파일만
|
|
189
|
+
gleam test # Gleam 테스트 실행
|
|
190
|
+
gleam format # Gleam 코드 포맷팅
|
|
191
|
+
\`\`\`
|
|
192
|
+
|
|
193
|
+
## Gleam FFI Convention
|
|
194
|
+
|
|
195
|
+
- FFI 파일명: \`<module_name>_ffi.mjs\`
|
|
196
|
+
- \`@external(javascript, "./<module>_ffi.mjs", "<function>")\` 형식으로 바인딩
|
|
197
|
+
- FFI 파일에는 React API 래핑만 작성. 위젯 로직은 반드시 Gleam으로 작성
|
|
198
|
+
|
|
199
|
+
## Mendix Widget Conventions
|
|
200
|
+
|
|
201
|
+
- 위젯 ID: \`mendix.${names.lowerCase}.${names.pascalCase}\`
|
|
202
|
+
- JSX 파일을 작성하지 않는다. 모든 React 로직은 Gleam + FFI로 구현
|
|
203
|
+
- Redraw 등 외부 Gleam React 라이브러리는 사용하지 않는다
|
|
204
|
+
|
|
205
|
+
## Code Style
|
|
206
|
+
|
|
207
|
+
- Gleam 파일: \`gleam format\` 사용
|
|
208
|
+
- FFI 파일(\`.ffi.mjs\`): React API 노출만 담당, 최소한으로 유지
|
|
209
|
+
- 한국어 주석 사용
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
await writeFile(join(targetDir, "CLAUDE.md"), content, "utf-8");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** README.md 생성 */
|
|
216
|
+
async function generateReadme(targetDir, names, pm, pmConfig) {
|
|
217
|
+
const { writeFile } = await import("node:fs/promises");
|
|
218
|
+
|
|
219
|
+
const runCmd = pm === "npm" ? "npm run" : pm;
|
|
220
|
+
|
|
221
|
+
const content = `# ${names.pascalCase}
|
|
222
|
+
|
|
223
|
+
Gleam 언어로 작성된 Mendix Pluggable Widget.
|
|
224
|
+
|
|
225
|
+
## 시작하기
|
|
226
|
+
|
|
227
|
+
### 사전 요구사항
|
|
228
|
+
|
|
229
|
+
- [Gleam](https://gleam.run/getting-started/installing/) (최신 버전)
|
|
230
|
+
- [Node.js](https://nodejs.org/) (v18+)
|
|
231
|
+
- ${pm}
|
|
232
|
+
|
|
233
|
+
### 설치
|
|
234
|
+
|
|
235
|
+
\`\`\`bash
|
|
236
|
+
gleam run -m scripts/install
|
|
237
|
+
\`\`\`
|
|
238
|
+
|
|
239
|
+
### 개발
|
|
240
|
+
|
|
241
|
+
\`\`\`bash
|
|
242
|
+
gleam run -m scripts/dev
|
|
243
|
+
\`\`\`
|
|
244
|
+
|
|
245
|
+
### 빌드
|
|
246
|
+
|
|
247
|
+
\`\`\`bash
|
|
248
|
+
gleam run -m scripts/build
|
|
249
|
+
\`\`\`
|
|
250
|
+
|
|
251
|
+
빌드 결과물(\`.mpk\`)은 \`dist/\` 디렉토리에 생성됩니다.
|
|
252
|
+
|
|
253
|
+
### 기타 명령어
|
|
254
|
+
|
|
255
|
+
\`\`\`bash
|
|
256
|
+
gleam run -m scripts/start # Mendix 테스트 프로젝트 연동
|
|
257
|
+
gleam run -m scripts/lint # ESLint 실행
|
|
258
|
+
gleam run -m scripts/lint_fix # ESLint 자동 수정
|
|
259
|
+
gleam run -m scripts/release # 릴리즈 빌드
|
|
260
|
+
gleam build --target javascript # Gleam → JS 컴파일만
|
|
261
|
+
gleam test # 테스트 실행
|
|
262
|
+
gleam format # 코드 포맷팅
|
|
263
|
+
\`\`\`
|
|
264
|
+
|
|
265
|
+
## 프로젝트 구조
|
|
266
|
+
|
|
267
|
+
\`\`\`
|
|
268
|
+
src/
|
|
269
|
+
widget/ # Gleam 위젯 코드
|
|
270
|
+
${names.snakeCase}.gleam # 메인 위젯 모듈
|
|
271
|
+
${names.snakeCase}_ffi.mjs # React FFI 어댑터
|
|
272
|
+
editor_config.gleam # Studio Pro 속성 패널
|
|
273
|
+
scripts/ # 빌드/개발 스크립트
|
|
274
|
+
${names.pascalCase}.js # Mendix 브릿지 진입점
|
|
275
|
+
${names.pascalCase}.xml # 위젯 속성 정의
|
|
276
|
+
\`\`\`
|
|
277
|
+
|
|
278
|
+
## 기술 스택
|
|
279
|
+
|
|
280
|
+
- **Gleam** → JavaScript 컴파일
|
|
281
|
+
- **Gleam FFI** — React API 직접 바인딩
|
|
282
|
+
- **Mendix Pluggable Widget** (React 19)
|
|
283
|
+
- **${pm}** — 패키지 매니저
|
|
284
|
+
|
|
285
|
+
## 라이센스
|
|
286
|
+
|
|
287
|
+
Apache-2.0
|
|
288
|
+
`;
|
|
289
|
+
|
|
290
|
+
await writeFile(join(targetDir, "README.md"), content, "utf-8");
|
|
291
|
+
}
|
package/src/naming.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 이름 변환 유틸리티
|
|
3
|
+
* 사용자 입력을 다양한 형식으로 변환한다.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 입력 문자열을 단어 배열로 분리 */
|
|
7
|
+
export function splitWords(input) {
|
|
8
|
+
// PascalCase / camelCase 경계에서 분리
|
|
9
|
+
let result = input.replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
10
|
+
// 숫자-문자 경계
|
|
11
|
+
result = result.replace(/([0-9])([a-zA-Z])/g, "$1 $2");
|
|
12
|
+
result = result.replace(/([a-zA-Z])([0-9])/g, "$1 $2");
|
|
13
|
+
// 구분자(-, _, 공백)로 분리
|
|
14
|
+
return result
|
|
15
|
+
.split(/[-_\s]+/)
|
|
16
|
+
.map((w) => w.trim().toLowerCase())
|
|
17
|
+
.filter((w) => w.length > 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** PascalCase: MyCoolWidget */
|
|
21
|
+
export function toPascalCase(words) {
|
|
22
|
+
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** snake_case: my_cool_widget */
|
|
26
|
+
export function toSnakeCase(words) {
|
|
27
|
+
return words.join("_");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** lowercase: mycoolwidget */
|
|
31
|
+
export function toLowerCase(words) {
|
|
32
|
+
return words.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Display Name: My Cool Widget */
|
|
36
|
+
export function toDisplayName(words) {
|
|
37
|
+
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** kebab-case: my-cool-widget */
|
|
41
|
+
export function toKebabCase(words) {
|
|
42
|
+
return words.join("-");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 모든 형식을 한 번에 생성 */
|
|
46
|
+
export function generateNames(input) {
|
|
47
|
+
const words = splitWords(input);
|
|
48
|
+
if (words.length === 0) return null;
|
|
49
|
+
return {
|
|
50
|
+
pascalCase: toPascalCase(words),
|
|
51
|
+
snakeCase: toSnakeCase(words),
|
|
52
|
+
lowerCase: toLowerCase(words),
|
|
53
|
+
displayName: toDisplayName(words),
|
|
54
|
+
kebabCase: toKebabCase(words),
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/pm.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 패키지 매니저 설정
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const PM_CONFIG = {
|
|
6
|
+
npm: { install: "npm install", runner: "npx" },
|
|
7
|
+
yarn: { install: "yarn install", runner: "npx" },
|
|
8
|
+
pnpm: { install: "pnpm install", runner: "pnpm exec" },
|
|
9
|
+
bun: { install: "bun install", runner: "bunx" },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const PM_CHOICES = Object.keys(PM_CONFIG);
|
|
13
|
+
|
|
14
|
+
/** 패키지 매니저별 명령어 반환 */
|
|
15
|
+
export function getPmConfig(pm) {
|
|
16
|
+
return PM_CONFIG[pm] || PM_CONFIG.npm;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 실행 환경에서 패키지 매니저 자동 감지 */
|
|
20
|
+
export function detectPm() {
|
|
21
|
+
const ua = process.env.npm_config_user_agent || "";
|
|
22
|
+
if (ua.startsWith("pnpm/")) return "pnpm";
|
|
23
|
+
if (ua.startsWith("yarn/")) return "yarn";
|
|
24
|
+
if (ua.startsWith("bun/")) return "bun";
|
|
25
|
+
return "npm";
|
|
26
|
+
}
|
package/src/prompts.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 인터랙티브 프롬프트 (node:readline 기반)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { stdin, stdout } from "node:process";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { splitWords } from "./naming.mjs";
|
|
10
|
+
import { PM_CHOICES, detectPm } from "./pm.mjs";
|
|
11
|
+
|
|
12
|
+
const BOLD = "\x1b[1m";
|
|
13
|
+
const DIM = "\x1b[2m";
|
|
14
|
+
const RESET = "\x1b[0m";
|
|
15
|
+
const CYAN = "\x1b[36m";
|
|
16
|
+
const YELLOW = "\x1b[33m";
|
|
17
|
+
|
|
18
|
+
/** 프로젝트 이름 검증 */
|
|
19
|
+
function validateName(name) {
|
|
20
|
+
if (!name || name.trim().length === 0) {
|
|
21
|
+
return "프로젝트 이름을 입력해주세요.";
|
|
22
|
+
}
|
|
23
|
+
const words = splitWords(name.trim());
|
|
24
|
+
if (words.length === 0) {
|
|
25
|
+
return "유효한 영문자를 포함해야 합니다.";
|
|
26
|
+
}
|
|
27
|
+
// 영문자, 숫자, -, _ 만 허용
|
|
28
|
+
if (!/^[a-zA-Z][a-zA-Z0-9\-_]*$/.test(name.trim())) {
|
|
29
|
+
return "영문자로 시작해야 하며, 영문자/숫자/-/_ 만 사용 가능합니다.";
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 인터랙티브 프롬프트로 설정 수집 */
|
|
35
|
+
export async function collectOptions(cliProjectName) {
|
|
36
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
37
|
+
let done = false;
|
|
38
|
+
|
|
39
|
+
// Ctrl+C 처리 (프롬프트 완료 전에만)
|
|
40
|
+
rl.on("close", () => {
|
|
41
|
+
if (!done) {
|
|
42
|
+
console.log("\n취소되었습니다.");
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let projectName = cliProjectName;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// 1. 프로젝트 이름
|
|
51
|
+
if (!projectName) {
|
|
52
|
+
projectName = await rl.question(
|
|
53
|
+
`${BOLD}프로젝트 이름:${RESET} `,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const nameError = validateName(projectName);
|
|
58
|
+
if (nameError) {
|
|
59
|
+
done = true;
|
|
60
|
+
rl.close();
|
|
61
|
+
console.error(`\n${YELLOW}오류: ${nameError}${RESET}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
projectName = projectName.trim();
|
|
66
|
+
|
|
67
|
+
// 디렉토리 충돌 확인
|
|
68
|
+
const targetDir = resolve(process.cwd(), projectName);
|
|
69
|
+
if (existsSync(targetDir)) {
|
|
70
|
+
done = true;
|
|
71
|
+
rl.close();
|
|
72
|
+
console.error(
|
|
73
|
+
`\n${YELLOW}오류: '${projectName}' 디렉토리가 이미 존재합니다.${RESET}`,
|
|
74
|
+
);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. 패키지 매니저 선택
|
|
79
|
+
const detected = detectPm();
|
|
80
|
+
console.log(
|
|
81
|
+
`\n${BOLD}패키지 매니저 선택:${RESET} ${DIM}(감지: ${detected})${RESET}`,
|
|
82
|
+
);
|
|
83
|
+
PM_CHOICES.forEach((pm, i) => {
|
|
84
|
+
const marker = pm === detected ? ` ${CYAN}← 감지됨${RESET}` : "";
|
|
85
|
+
console.log(` ${i + 1}) ${pm}${marker}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
let pmAnswer = "";
|
|
89
|
+
try {
|
|
90
|
+
pmAnswer = await rl.question(
|
|
91
|
+
`선택 ${DIM}(1-${PM_CHOICES.length}, 기본: ${detected})${RESET}: `,
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
// stdin이 닫혀도 기본값 사용
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let pm = detected;
|
|
98
|
+
const pmIndex = parseInt(pmAnswer, 10);
|
|
99
|
+
if (pmIndex >= 1 && pmIndex <= PM_CHOICES.length) {
|
|
100
|
+
pm = PM_CHOICES[pmIndex - 1];
|
|
101
|
+
} else if (pmAnswer.trim() && PM_CHOICES.includes(pmAnswer.trim())) {
|
|
102
|
+
pm = pmAnswer.trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
done = true;
|
|
106
|
+
rl.close();
|
|
107
|
+
return { projectName, pm };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
done = true;
|
|
110
|
+
rl.close();
|
|
111
|
+
if (err.code === "ERR_USE_AFTER_CLOSE") {
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 파일 복사 + 템플릿 치환
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdir, readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** 바이너리 판별용 확장자 */
|
|
9
|
+
const BINARY_EXTS = new Set([".png", ".jpg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot"]);
|
|
10
|
+
|
|
11
|
+
/** 재귀적으로 디렉토리 내 모든 파일 경로를 수집 */
|
|
12
|
+
async function walkDir(dir) {
|
|
13
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
14
|
+
const files = [];
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const fullPath = join(dir, entry.name);
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
files.push(...(await walkDir(fullPath)));
|
|
19
|
+
} else {
|
|
20
|
+
files.push(fullPath);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// npm publish 시 제외되는 dotfile을 언더스코어 접두사로 보관하고 복원
|
|
27
|
+
const DOTFILE_MAP = { _gitignore: ".gitignore" };
|
|
28
|
+
|
|
29
|
+
/** 파일명에서 플레이스홀더 치환 + dotfile 복원 */
|
|
30
|
+
function replaceFileName(name, names) {
|
|
31
|
+
if (DOTFILE_MAP[name]) return DOTFILE_MAP[name];
|
|
32
|
+
return name
|
|
33
|
+
.replace(/__WidgetName__/g, names.pascalCase)
|
|
34
|
+
.replace(/__widget_name__/g, names.snakeCase);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 파일 내용에서 플레이스홀더 치환 */
|
|
38
|
+
function replaceContent(content, names, pmConfig) {
|
|
39
|
+
return content
|
|
40
|
+
.replace(/\{\{PASCAL_CASE\}\}/g, names.pascalCase)
|
|
41
|
+
.replace(/\{\{SNAKE_CASE\}\}/g, names.snakeCase)
|
|
42
|
+
.replace(/\{\{LOWERCASE\}\}/g, names.lowerCase)
|
|
43
|
+
.replace(/\{\{DISPLAY_NAME\}\}/g, names.displayName)
|
|
44
|
+
.replace(/\{\{KEBAB_CASE\}\}/g, names.kebabCase)
|
|
45
|
+
.replace(/\{\{INSTALL_COMMAND\}\}/g, pmConfig.install)
|
|
46
|
+
.replace(/\{\{RUNNER\}\}/g, pmConfig.runner);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 템플릿을 대상 디렉토리에 스케폴딩
|
|
51
|
+
* @param {string} templateDir - 템플릿 디렉토리 경로
|
|
52
|
+
* @param {string} targetDir - 생성할 프로젝트 디렉토리 경로
|
|
53
|
+
* @param {object} names - 이름 변환 결과
|
|
54
|
+
* @param {object} pmConfig - 패키지 매니저 설정
|
|
55
|
+
*/
|
|
56
|
+
export async function scaffold(templateDir, targetDir, names, pmConfig) {
|
|
57
|
+
const files = await walkDir(templateDir);
|
|
58
|
+
const created = [];
|
|
59
|
+
|
|
60
|
+
for (const srcPath of files) {
|
|
61
|
+
// 템플릿 기준 상대 경로
|
|
62
|
+
const relPath = relative(templateDir, srcPath);
|
|
63
|
+
|
|
64
|
+
// 경로의 각 부분에서 파일명 치환
|
|
65
|
+
const destRelPath = relPath
|
|
66
|
+
.split(/[\\/]/)
|
|
67
|
+
.map((part) => replaceFileName(part, names))
|
|
68
|
+
.join("/");
|
|
69
|
+
|
|
70
|
+
const destPath = join(targetDir, destRelPath);
|
|
71
|
+
const ext = srcPath.substring(srcPath.lastIndexOf(".")).toLowerCase();
|
|
72
|
+
|
|
73
|
+
// 디렉토리 생성
|
|
74
|
+
await mkdir(join(destPath, ".."), { recursive: true });
|
|
75
|
+
|
|
76
|
+
if (BINARY_EXTS.has(ext)) {
|
|
77
|
+
// 바이너리 파일은 그대로 복사
|
|
78
|
+
await copyFile(srcPath, destPath);
|
|
79
|
+
} else {
|
|
80
|
+
// 텍스트 파일은 내용 치환
|
|
81
|
+
const content = await readFile(srcPath, "utf-8");
|
|
82
|
+
const replaced = replaceContent(content, names, pmConfig);
|
|
83
|
+
await writeFile(destPath, replaced, "utf-8");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
created.push(destRelPath);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return created;
|
|
90
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Set the default behavior, in case people don't have core.autocrlf set.
|
|
2
|
+
* text=auto
|
|
3
|
+
|
|
4
|
+
# Explicitly declare text files you want to always be normalized and converted
|
|
5
|
+
# to native line endings on checkout.
|
|
6
|
+
*.ts text eol=lf
|
|
7
|
+
*.tsx text eol=lf
|
|
8
|
+
*.js text eol=lf
|
|
9
|
+
*.jsx text eol=lf
|
|
10
|
+
*.css text eol=lf
|
|
11
|
+
*.scss text eol=lf
|
|
12
|
+
*.json text eol=lf
|
|
13
|
+
*.xml text eol=lf
|
|
14
|
+
*.md text eol=lf
|
|
15
|
+
*.gitattributes eol=lf
|
|
16
|
+
*.gitignore eol=lf
|
|
17
|
+
|
|
18
|
+
# Denote all files that are truly binary and should not be modified.
|
|
19
|
+
*.png binary
|
|
20
|
+
*.jpg binary
|
|
21
|
+
*.gif binary
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tests/testProject/
|
package/template/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
The Apache License v2.0
|
|
2
|
+
|
|
3
|
+
Copyright © Mendix Technology BV 2026. All rights reserved.
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 의존성
|
|
2
|
+
node_modules/
|
|
3
|
+
.yarn/
|
|
4
|
+
.pnp.*
|
|
5
|
+
|
|
6
|
+
# 패키지 매니저 lock 파일
|
|
7
|
+
package-lock.json
|
|
8
|
+
yarn.lock
|
|
9
|
+
pnpm-lock.yaml
|
|
10
|
+
bun.lockb
|
|
11
|
+
bun.lock
|
|
12
|
+
|
|
13
|
+
# 패키지 매니저 캐시/설정
|
|
14
|
+
.npmrc
|
|
15
|
+
.yarnrc
|
|
16
|
+
.yarnrc.yml
|
|
17
|
+
.pnpmfile.cjs
|
|
18
|
+
.npmignore
|
|
19
|
+
|
|
20
|
+
# Gleam 컴파일 출력
|
|
21
|
+
build/
|
|
22
|
+
|
|
23
|
+
# Mendix 위젯 빌드 출력
|
|
24
|
+
dist/
|
|
25
|
+
|
|
26
|
+
# 환경 변수
|
|
27
|
+
.env
|
|
28
|
+
|
|
29
|
+
# 로그
|
|
30
|
+
*.log
|
|
31
|
+
|
|
32
|
+
# OS
|
|
33
|
+
.DS_Store
|
|
34
|
+
|
|
35
|
+
# IDE
|
|
36
|
+
.idea/
|
|
37
|
+
.vscode/
|
|
38
|
+
*.launch
|
|
39
|
+
|
|
40
|
+
# Mendix 테스트 프로젝트
|
|
41
|
+
tests/testProject/
|
|
42
|
+
|
|
43
|
+
# 테스트 산출물
|
|
44
|
+
coverage/
|
|
45
|
+
**/e2e/diffs/
|
|
46
|
+
**/screenshot/
|
|
47
|
+
**/screenshot-results/
|
|
48
|
+
**/artifacts/
|