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/README.md +1 -1
- package/package.json +1 -1
- package/src/i18n.mjs +334 -0
- package/src/index.mjs +211 -652
- package/src/prompts.mjs +142 -116
- package/src/scaffold.mjs +97 -89
- package/src/templates/claude_md.mjs +105 -0
- package/src/templates/readme_md.mjs +785 -0
- package/src/templates/widgets_readme.mjs +272 -0
- package/template/_gitignore +58 -58
- package/template/gleam.toml +11 -11
- package/template/src/__widget_name__.gleam +12 -12
- package/template/src/components/hello_world.gleam +13 -13
- package/template/src/editor_config.gleam +18 -18
- package/template/src/editor_preview.gleam +12 -12
- package/template/widgets/README.md +1 -82
package/src/prompts.mjs
CHANGED
|
@@ -1,116 +1,142 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
rl.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
}
|