create-mendix-widget-gleam 2.0.12 → 2.0.13

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/src/prompts.mjs CHANGED
@@ -1,116 +1,142 @@
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
- }
1
+ /**
2
+ * Interactive prompts (node:readline based)
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
+ import { LANG_CHOICES, getLangLabel, t } from "./i18n.mjs";
12
+
13
+ const BOLD = "\x1b[1m";
14
+ const DIM = "\x1b[2m";
15
+ const RESET = "\x1b[0m";
16
+ const CYAN = "\x1b[36m";
17
+ const YELLOW = "\x1b[33m";
18
+
19
+ /** Project name validation */
20
+ function validateName(lang, name) {
21
+ if (!name || name.trim().length === 0) {
22
+ return t(lang, "validate.nameRequired");
23
+ }
24
+ const words = splitWords(name.trim());
25
+ if (words.length === 0) {
26
+ return t(lang, "validate.needAlpha");
27
+ }
28
+ if (!/^[a-zA-Z][a-zA-Z0-9\-_]*$/.test(name.trim())) {
29
+ return t(lang, "validate.invalidChars");
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /** Collect options via interactive prompts */
35
+ export async function collectOptions(cliProjectName) {
36
+ const rl = createInterface({ input: stdin, output: stdout });
37
+ let done = false;
38
+
39
+ rl.on("close", () => {
40
+ if (!done) {
41
+ console.log("\nCancelled.");
42
+ process.exit(0);
43
+ }
44
+ });
45
+
46
+ let projectName = cliProjectName;
47
+
48
+ try {
49
+ // 1. Language selection (multilingual labels — shown before language is chosen)
50
+ console.log(
51
+ `\n${BOLD}Language / 언어 / 言語:${RESET}`,
52
+ );
53
+ LANG_CHOICES.forEach((code, i) => {
54
+ const label = getLangLabel(code);
55
+ console.log(` ${i + 1}) ${label}`);
56
+ });
57
+
58
+ let langAnswer = "";
59
+ try {
60
+ langAnswer = await rl.question(
61
+ `${DIM}(1-${LANG_CHOICES.length}, default: 1)${RESET}: `,
62
+ );
63
+ } catch {
64
+ // stdin closed — use default
65
+ }
66
+
67
+ let lang = "en";
68
+ const langIndex = parseInt(langAnswer, 10);
69
+ if (langIndex >= 1 && langIndex <= LANG_CHOICES.length) {
70
+ lang = LANG_CHOICES[langIndex - 1];
71
+ }
72
+
73
+ // 2. Project name
74
+ if (!projectName) {
75
+ projectName = await rl.question(
76
+ `${BOLD}${t(lang, "prompt.projectName")}${RESET} `,
77
+ );
78
+ }
79
+
80
+ const nameError = validateName(lang, projectName);
81
+ if (nameError) {
82
+ done = true;
83
+ rl.close();
84
+ console.error(`\n${YELLOW}${nameError}${RESET}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ projectName = projectName.trim();
89
+
90
+ // Check directory conflict
91
+ const targetDir = resolve(process.cwd(), projectName);
92
+ if (existsSync(targetDir)) {
93
+ done = true;
94
+ rl.close();
95
+ console.error(
96
+ `\n${YELLOW}${t(lang, "error.dirExists", { name: projectName })}${RESET}`,
97
+ );
98
+ process.exit(1);
99
+ }
100
+
101
+ // 3. Package manager selection
102
+ const detected = detectPm();
103
+ console.log(
104
+ `\n${BOLD}${t(lang, "prompt.pmSelect")}${RESET} ${DIM}(${t(lang, "prompt.pmDetected", { detected })})${RESET}`,
105
+ );
106
+ PM_CHOICES.forEach((pm, i) => {
107
+ const marker =
108
+ pm === detected
109
+ ? ` ${CYAN}${t(lang, "prompt.pmDetectedMarker")}${RESET}`
110
+ : "";
111
+ console.log(` ${i + 1}) ${pm}${marker}`);
112
+ });
113
+
114
+ let pmAnswer = "";
115
+ try {
116
+ pmAnswer = await rl.question(
117
+ `${t(lang, "prompt.pmChoose", { count: PM_CHOICES.length, default: detected })}: `,
118
+ );
119
+ } catch {
120
+ // stdin closed — use default
121
+ }
122
+
123
+ let pm = detected;
124
+ const pmIndex = parseInt(pmAnswer, 10);
125
+ if (pmIndex >= 1 && pmIndex <= PM_CHOICES.length) {
126
+ pm = PM_CHOICES[pmIndex - 1];
127
+ } else if (pmAnswer.trim() && PM_CHOICES.includes(pmAnswer.trim())) {
128
+ pm = pmAnswer.trim();
129
+ }
130
+
131
+ done = true;
132
+ rl.close();
133
+ return { projectName, pm, lang };
134
+ } catch (err) {
135
+ done = true;
136
+ rl.close();
137
+ if (err.code === "ERR_USE_AFTER_CLOSE") {
138
+ process.exit(0);
139
+ }
140
+ throw err;
141
+ }
142
+ }
package/src/scaffold.mjs CHANGED
@@ -1,89 +1,97 @@
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
- ;
46
- }
47
-
48
- /**
49
- * 템플릿을 대상 디렉토리에 스케폴딩
50
- * @param {string} templateDir - 템플릿 디렉토리 경로
51
- * @param {string} targetDir - 생성할 프로젝트 디렉토리 경로
52
- * @param {object} names - 이름 변환 결과
53
- * @param {object} pmConfig - 패키지 매니저 설정
54
- */
55
- export async function scaffold(templateDir, targetDir, names, pmConfig) {
56
- const files = await walkDir(templateDir);
57
- const created = [];
58
-
59
- for (const srcPath of files) {
60
- // 템플릿 기준 상대 경로
61
- const relPath = relative(templateDir, srcPath);
62
-
63
- // 경로의 부분에서 파일명 치환
64
- const destRelPath = relPath
65
- .split(/[\\/]/)
66
- .map((part) => replaceFileName(part, names))
67
- .join("/");
68
-
69
- const destPath = join(targetDir, destRelPath);
70
- const ext = srcPath.substring(srcPath.lastIndexOf(".")).toLowerCase();
71
-
72
- // 디렉토리 생성
73
- await mkdir(join(destPath, ".."), { recursive: true });
74
-
75
- if (BINARY_EXTS.has(ext)) {
76
- // 바이너리 파일은 그대로 복사
77
- await copyFile(srcPath, destPath);
78
- } else {
79
- // 텍스트 파일은 내용 치환
80
- const content = await readFile(srcPath, "utf-8");
81
- const replaced = replaceContent(content, names, pmConfig);
82
- await writeFile(destPath, replaced, "utf-8");
83
- }
84
-
85
- created.push(destRelPath);
86
- }
87
-
88
- return created;
89
- }
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, templateComments) {
39
+ let result = 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
+
46
+ if (templateComments) {
47
+ result = result.replace(/\{\{I18N:(\w+)\}\}/g, (_, key) => {
48
+ return templateComments[key] ?? `{{I18N:${key}}}`;
49
+ });
50
+ }
51
+
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * 템플릿을 대상 디렉토리에 스케폴딩
57
+ * @param {string} templateDir - 템플릿 디렉토리 경로
58
+ * @param {string} targetDir - 생성할 프로젝트 디렉토리 경로
59
+ * @param {object} names - 이름 변환 결과
60
+ * @param {object} pmConfig - 패키지 매니저 설정
61
+ * @param {object} [templateComments] - i18n 템플릿 주석 ({{I18N:*}} 치환용)
62
+ */
63
+ export async function scaffold(templateDir, targetDir, names, pmConfig, templateComments) {
64
+ const files = await walkDir(templateDir);
65
+ const created = [];
66
+
67
+ for (const srcPath of files) {
68
+ // 템플릿 기준 상대 경로
69
+ const relPath = relative(templateDir, srcPath);
70
+
71
+ // 경로의 각 부분에서 파일명 치환
72
+ const destRelPath = relPath
73
+ .split(/[\\/]/)
74
+ .map((part) => replaceFileName(part, names))
75
+ .join("/");
76
+
77
+ const destPath = join(targetDir, destRelPath);
78
+ const ext = srcPath.substring(srcPath.lastIndexOf(".")).toLowerCase();
79
+
80
+ // 디렉토리 생성
81
+ await mkdir(join(destPath, ".."), { recursive: true });
82
+
83
+ if (BINARY_EXTS.has(ext)) {
84
+ // 바이너리 파일은 그대로 복사
85
+ await copyFile(srcPath, destPath);
86
+ } else {
87
+ // 텍스트 파일은 내용 치환
88
+ const content = await readFile(srcPath, "utf-8");
89
+ const replaced = replaceContent(content, names, pmConfig, templateComments);
90
+ await writeFile(destPath, replaced, "utf-8");
91
+ }
92
+
93
+ created.push(destRelPath);
94
+ }
95
+
96
+ return created;
97
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * CLAUDE.md template — always generated in English.
3
+ * Only the Code Style comment language instruction varies by lang.
4
+ */
5
+
6
+ const COMMENT_LANG_INSTRUCTIONS = {
7
+ en: "Use English comments",
8
+ ko: "Use Korean comments",
9
+ ja: "Use Japanese comments",
10
+ };
11
+
12
+ export function generateClaudeMdContent(lang, names, pm, pmConfig) {
13
+ const commentInstruction =
14
+ COMMENT_LANG_INSTRUCTIONS[lang] ?? COMMENT_LANG_INSTRUCTIONS["en"];
15
+
16
+ return `# ${names.pascalCase}
17
+
18
+ A project for developing Mendix Pluggable Widgets with Gleam. Widgets are implemented using only Gleam + [glendix](https://hexdocs.pm/glendix/) bindings, without JSX.
19
+
20
+ ## Commands
21
+
22
+ \`\`\`bash
23
+ gleam run -m glendix/install # Install dependencies (Gleam deps + npm + bindings.json code generation)
24
+ gleam run -m glendix/build # Production build (.mpk output)
25
+ gleam run -m glendix/dev # Dev server (HMR, port 3000)
26
+ gleam run -m glendix/start # Link with Mendix test project
27
+ gleam run -m glendix/release # Release build
28
+ gleam run -m glendix/lint # Run ESLint
29
+ gleam run -m glendix/lint_fix # ESLint auto-fix
30
+ gleam run -m glendix/marketplace # Search/download Marketplace widgets
31
+ gleam test # Run tests
32
+ gleam format # Format code
33
+ \`\`\`
34
+
35
+ If you add external React packages to bindings.json, install the npm package manually before running \`glendix/install\`.
36
+
37
+ ## Hard Rules
38
+
39
+ IMPORTANT: Breaking these rules will break the build or compromise the architecture.
40
+
41
+ - **Do not write JSX/JS files directly.** All widget logic and UI must be written in Gleam
42
+ - **Do not write FFI files (.mjs) in the widget project.** React/Mendix FFI is provided by the glendix package
43
+ - **Do not manually manage bridge JS files (src/*.js).** glendix auto-generates/deletes them at build time
44
+ - **Do not use external Gleam React libraries such as Redraw.** Use only glendix for React bindings
45
+ - The Gleam compilation output path (\`build/dev/javascript/{gleam.toml name}/\`) must match the Rollup input path
46
+ - Mendix widget names allow only alphabetic characters (a-zA-Z)
47
+
48
+ ## Code Style
49
+
50
+ - Format with \`gleam format\`
51
+ - ${commentInstruction}
52
+ - Do not manually edit compiled JS output (\`build/\`)
53
+
54
+ ## Architecture
55
+
56
+ Widget entry point signature: \`pub fn widget(props: JsProps) -> ReactElement\` — identical to a React functional component.
57
+
58
+ - \`src/${names.snakeCase}.gleam\` — Main widget (called by Mendix runtime)
59
+ - \`src/editor_config.gleam\` — Studio Pro property panel configuration
60
+ - \`src/editor_preview.gleam\` — Studio Pro design view preview
61
+ - \`src/components/\` — Shared components
62
+ - \`src/${names.pascalCase}.xml\` — Widget property definitions. Adding \`<property>\` triggers automatic type generation by the build tool
63
+ - \`src/package.xml\` — Mendix package manifest
64
+ - \`bindings.json\` — External React component binding configuration
65
+ - \`widgets/\` — .mpk widget file bindings (used via \`glendix/widget\`)
66
+
67
+ ## Build Pipeline
68
+
69
+ \`\`\`
70
+ src/*.gleam → gleam build → build/dev/javascript/**/*.mjs → Bridge JS (auto-generated) → Rollup → dist/**/*.mpk
71
+ \`\`\`
72
+
73
+ ## Mendix Widget Conventions
74
+
75
+ - Widget ID: \`mendix.${names.lowerCase}.${names.pascalCase}\`
76
+ - \`packagePath: "mendix"\` in \`package.json\` determines the deployment path
77
+ - \`needsEntityContext="true"\` → Requires Mendix data context
78
+ - \`offlineCapable="true"\` → Offline support
79
+ - \`.mpk\` output: \`dist/\` directory
80
+ - Test project: \`./tests/testProject\`
81
+
82
+ ## Key Concepts
83
+
84
+ - Mendix props (\`JsProps\`) are accessed via \`mendix.get_prop\`/\`mendix.get_string_prop\` etc.
85
+ - Mendix complex types (\`EditableValue\`, \`ActionValue\`, \`ListValue\`) are opaque types with FFI accessors
86
+ - JS \`undefined\` ↔ Gleam \`Option\` conversion is handled automatically at the FFI boundary
87
+ - HTML attributes use the Attribute list API: \`[attribute.class("x"), event.on_click(handler)]\`
88
+ - Gleam tuples \`#(a, b)\` = JS \`[a, b]\` — directly compatible with \`useState\` return values
89
+
90
+ ## Reference Docs
91
+
92
+ For detailed glendix API and Gleam syntax, see:
93
+
94
+ - docs/glendix_guide.md — Complete React/Mendix bindings guide (elements, Hooks, events, Mendix types, practical patterns, troubleshooting)
95
+ - docs/gleam_language_tour.md — Gleam syntax reference (types, pattern matching, FFI, use keyword, etc.)
96
+
97
+ ## Mendix Documentation Sources
98
+
99
+ docs.mendix.com is not accessible. Use GitHub raw sources:
100
+
101
+ - Pluggable Widgets API: \`https://github.com/mendix/docs/blob/development/content/en/docs/apidocs-mxsdk/apidocs/pluggable-widgets/\`
102
+ - Build tools source: \`https://github.com/mendix/widgets-tools\`
103
+ - Official widget examples: \`https://github.com/mendix/web-widgets\`
104
+ `;
105
+ }