Skip to content

Commit 1bba596

Browse files
authored
Merge pull request #68 from engalar/feat/widget-engine-v2
feat: pluggable widget engine v2
2 parents a52e11e + 4d40cf6 commit 1bba596

82 files changed

Lines changed: 45162 additions & 12485 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/mxcli/cmd_widget.go

Lines changed: 274 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,39 @@ var widgetListCmd = &cobra.Command{
4444
RunE: runWidgetList,
4545
}
4646

47+
var widgetInitCmd = &cobra.Command{
48+
Use: "init",
49+
Short: "Extract definitions for all project widgets",
50+
Long: `Scan the project's widgets/ directory, extract .def.json for each .mpk,
51+
and generate skill documentation in .claude/skills/widgets/.
52+
53+
This enables CREATE PAGE to use any project widget via the pluggable engine.`,
54+
RunE: runWidgetInit,
55+
}
56+
57+
var widgetDocsCmd = &cobra.Command{
58+
Use: "docs",
59+
Short: "Generate widget skill documentation",
60+
Long: `Generate per-widget markdown documentation in .claude/skills/widgets/ from .mpk definitions.`,
61+
RunE: runWidgetDocs,
62+
}
63+
4764
func init() {
4865
widgetExtractCmd.Flags().String("mpk", "", "Path to .mpk widget package file")
4966
widgetExtractCmd.Flags().StringP("output", "o", "", "Output directory (default: .mxcli/widgets/)")
5067
widgetExtractCmd.Flags().String("mdl-name", "", "Override the MDL keyword name (default: derived from widget name)")
5168
widgetExtractCmd.MarkFlagRequired("mpk")
5269

70+
widgetInitCmd.Flags().StringP("project", "p", "", "Path to .mpr project file")
71+
widgetInitCmd.MarkFlagRequired("project")
72+
73+
widgetDocsCmd.Flags().StringP("project", "p", "", "Path to .mpr project file")
74+
widgetDocsCmd.MarkFlagRequired("project")
75+
5376
widgetCmd.AddCommand(widgetExtractCmd)
5477
widgetCmd.AddCommand(widgetListCmd)
78+
widgetCmd.AddCommand(widgetInitCmd)
79+
widgetCmd.AddCommand(widgetDocsCmd)
5580
rootCmd.AddCommand(widgetCmd)
5681
}
5782

@@ -115,78 +140,277 @@ func deriveMDLName(widgetID string) string {
115140
return strings.ToUpper(name)
116141
}
117142

118-
// generateDefJSON creates a WidgetDefinition from an mpk.WidgetDefinition.
143+
// generateDefJSON creates a skeleton WidgetDefinition from an mpk.WidgetDefinition.
144+
// Properties are handled explicitly from MDL via the engine's explicit property pass,
145+
// so no propertyMappings or childSlots are generated here.
119146
func generateDefJSON(mpkDef *mpk.WidgetDefinition, mdlName string) *executor.WidgetDefinition {
147+
widgetKind := "custom"
148+
if mpkDef.IsPluggable {
149+
widgetKind = "pluggable"
150+
}
120151
def := &executor.WidgetDefinition{
121152
WidgetID: mpkDef.ID,
122153
MDLName: mdlName,
154+
WidgetKind: widgetKind,
123155
TemplateFile: strings.ToLower(mdlName) + ".json",
124156
DefaultEditable: "Always",
125157
}
126158

127-
// Build property mappings by inferring operations from XML types
128-
var mappings []executor.PropertyMapping
129-
var childSlots []executor.ChildSlotMapping
130-
131-
for _, prop := range mpkDef.Properties {
132-
normalizedType := mpk.NormalizeType(prop.Type)
133-
134-
switch normalizedType {
159+
// Generate property mappings and child slots from MPK property definitions.
160+
// Two passes: datasource first (association depends on entityContext set by datasource).
161+
var assocMappings []executor.PropertyMapping
162+
for _, p := range mpkDef.Properties {
163+
switch p.Type {
164+
case "widgets":
165+
container := strings.ToUpper(p.Key)
166+
if p.Key == "content" {
167+
container = "TEMPLATE"
168+
}
169+
def.ChildSlots = append(def.ChildSlots, executor.ChildSlotMapping{
170+
PropertyKey: p.Key,
171+
MDLContainer: container,
172+
Operation: "widgets",
173+
})
174+
case "datasource":
175+
def.PropertyMappings = append(def.PropertyMappings, executor.PropertyMapping{
176+
PropertyKey: p.Key,
177+
Source: "DataSource",
178+
Operation: "datasource",
179+
})
135180
case "attribute":
136-
mappings = append(mappings, executor.PropertyMapping{
137-
PropertyKey: prop.Key,
181+
def.PropertyMappings = append(def.PropertyMappings, executor.PropertyMapping{
182+
PropertyKey: p.Key,
138183
Source: "Attribute",
139184
Operation: "attribute",
140185
})
141186
case "association":
142-
mappings = append(mappings, executor.PropertyMapping{
143-
PropertyKey: prop.Key,
187+
assocMappings = append(assocMappings, executor.PropertyMapping{
188+
PropertyKey: p.Key,
144189
Source: "Association",
145190
Operation: "association",
146191
})
147-
case "datasource":
148-
mappings = append(mappings, executor.PropertyMapping{
149-
PropertyKey: prop.Key,
150-
Source: "DataSource",
151-
Operation: "datasource",
152-
})
153-
case "widgets":
154-
// Widgets properties become child slots
155-
containerName := strings.ToUpper(prop.Key)
156-
if containerName == "CONTENT" {
157-
containerName = "TEMPLATE"
158-
}
159-
childSlots = append(childSlots, executor.ChildSlotMapping{
160-
PropertyKey: prop.Key,
161-
MDLContainer: containerName,
162-
Operation: "widgets",
163-
})
164192
case "selection":
165-
mappings = append(mappings, executor.PropertyMapping{
166-
PropertyKey: prop.Key,
193+
def.PropertyMappings = append(def.PropertyMappings, executor.PropertyMapping{
194+
PropertyKey: p.Key,
167195
Source: "Selection",
168196
Operation: "selection",
169-
Default: prop.DefaultValue,
197+
Default: p.DefaultValue,
170198
})
171-
case "boolean", "string", "enumeration", "integer", "decimal":
172-
mapping := executor.PropertyMapping{
173-
PropertyKey: prop.Key,
199+
case "boolean", "integer", "decimal", "string", "enumeration":
200+
m := executor.PropertyMapping{
201+
PropertyKey: p.Key,
174202
Operation: "primitive",
175203
}
176-
if prop.DefaultValue != "" {
177-
mapping.Value = prop.DefaultValue
204+
if p.DefaultValue != "" {
205+
m.Value = p.DefaultValue
178206
}
179-
mappings = append(mappings, mapping)
180-
// Skip action, expression, textTemplate, object, icon, image, file — too complex for auto-mapping
207+
def.PropertyMappings = append(def.PropertyMappings, m)
181208
}
182209
}
183-
184-
def.PropertyMappings = mappings
185-
def.ChildSlots = childSlots
210+
// Append association mappings after datasource (association requires prior entityContext)
211+
def.PropertyMappings = append(def.PropertyMappings, assocMappings...)
186212

187213
return def
188214
}
189215

216+
func runWidgetInit(cmd *cobra.Command, args []string) error {
217+
projectPath, _ := cmd.Flags().GetString("project")
218+
projectDir := filepath.Dir(projectPath)
219+
widgetsDir := filepath.Join(projectDir, "widgets")
220+
outputDir := filepath.Join(projectDir, ".mxcli", "widgets")
221+
222+
// Load built-in registry to skip widgets that already have hand-crafted definitions
223+
builtinRegistry, _ := executor.NewWidgetRegistry()
224+
225+
// Scan widgets/ for .mpk files
226+
matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk"))
227+
if err != nil {
228+
return fmt.Errorf("failed to scan widgets directory: %w", err)
229+
}
230+
if len(matches) == 0 {
231+
fmt.Println("No .mpk files found in widgets/ directory.")
232+
return nil
233+
}
234+
235+
if err := os.MkdirAll(outputDir, 0755); err != nil {
236+
return fmt.Errorf("failed to create output directory: %w", err)
237+
}
238+
239+
var extracted, skipped int
240+
for _, mpkPath := range matches {
241+
mpkDef, err := mpk.ParseMPK(mpkPath)
242+
if err != nil {
243+
log.Printf("warning: skipping %s: %v", filepath.Base(mpkPath), err)
244+
skipped++
245+
continue
246+
}
247+
248+
mdlName := deriveMDLName(mpkDef.ID)
249+
filename := strings.ToLower(mdlName) + ".def.json"
250+
outPath := filepath.Join(outputDir, filename)
251+
252+
// Skip widgets that have hand-crafted built-in definitions (e.g., COMBOBOX, GALLERY)
253+
if builtinRegistry != nil {
254+
if _, ok := builtinRegistry.GetByWidgetID(mpkDef.ID); ok {
255+
skipped++
256+
continue
257+
}
258+
}
259+
260+
// Skip if already exists on disk
261+
if _, err := os.Stat(outPath); err == nil {
262+
skipped++
263+
continue
264+
}
265+
266+
defJSON := generateDefJSON(mpkDef, mdlName)
267+
data, err := json.MarshalIndent(defJSON, "", " ")
268+
if err != nil {
269+
log.Printf("warning: skipping %s: %v", mpkDef.ID, err)
270+
skipped++
271+
continue
272+
}
273+
data = append(data, '\n')
274+
275+
if err := os.WriteFile(outPath, data, 0644); err != nil {
276+
return fmt.Errorf("failed to write %s: %w", outPath, err)
277+
}
278+
kind := "custom"
279+
if mpkDef.IsPluggable {
280+
kind = "pluggable"
281+
}
282+
fmt.Printf(" %-12s %-20s %s\n", kind, mdlName, mpkDef.ID)
283+
extracted++
284+
}
285+
286+
fmt.Printf("\nExtracted: %d, Skipped: %d (existing or unparseable)\n", extracted, skipped)
287+
288+
// Also generate docs
289+
fmt.Println("\nGenerating widget documentation...")
290+
return generateWidgetDocs(projectDir)
291+
}
292+
293+
func runWidgetDocs(cmd *cobra.Command, args []string) error {
294+
projectPath, _ := cmd.Flags().GetString("project")
295+
projectDir := filepath.Dir(projectPath)
296+
return generateWidgetDocs(projectDir)
297+
}
298+
299+
func generateWidgetDocs(projectDir string) error {
300+
widgetsDir := filepath.Join(projectDir, "widgets")
301+
docsDir := filepath.Join(projectDir, ".claude", "skills", "widgets")
302+
// Also try .ai-context
303+
if _, err := os.Stat(filepath.Join(projectDir, ".ai-context")); err == nil {
304+
docsDir = filepath.Join(projectDir, ".ai-context", "skills", "widgets")
305+
}
306+
307+
if err := os.MkdirAll(docsDir, 0755); err != nil {
308+
return fmt.Errorf("failed to create docs directory: %w", err)
309+
}
310+
311+
matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk"))
312+
if err != nil {
313+
return fmt.Errorf("failed to scan widgets directory: %w", err)
314+
}
315+
316+
var generated int
317+
var indexEntries []string
318+
319+
for _, mpkPath := range matches {
320+
mpkDef, err := mpk.ParseMPK(mpkPath)
321+
if err != nil {
322+
continue
323+
}
324+
325+
mdlName := deriveMDLName(mpkDef.ID)
326+
filename := strings.ToLower(mdlName) + ".md"
327+
outPath := filepath.Join(docsDir, filename)
328+
329+
doc := generateWidgetDoc(mpkDef, mdlName)
330+
331+
if err := os.WriteFile(outPath, []byte(doc), 0644); err != nil {
332+
log.Printf("warning: failed to write %s: %v", filename, err)
333+
continue
334+
}
335+
336+
kind := "CUSTOMWIDGET"
337+
if mpkDef.IsPluggable {
338+
kind = "PLUGGABLEWIDGET"
339+
}
340+
indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | %s | `%s` | %s | %d |",
341+
kind, mdlName, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties)))
342+
generated++
343+
}
344+
345+
// Write index
346+
var indexBuf strings.Builder
347+
indexBuf.WriteString("# Available Widgets\n\n")
348+
indexBuf.WriteString("Generated by `mxcli widget docs`. See individual files for property details.\n\n")
349+
indexBuf.WriteString("| Prefix | Name | Widget ID | Display Name | Props |\n")
350+
indexBuf.WriteString("|--------|------|-----------|--------------|-------|\n")
351+
for _, entry := range indexEntries {
352+
indexBuf.WriteString(entry)
353+
indexBuf.WriteString("\n")
354+
}
355+
indexBuf.WriteString("\n**Usage in MDL:**\n```sql\n")
356+
indexBuf.WriteString("-- React pluggable widgets\n")
357+
indexBuf.WriteString("PLUGGABLEWIDGET 'com.mendix.widget.custom.badge.Badge' badge1\n\n")
358+
indexBuf.WriteString("-- Legacy custom widgets\n")
359+
indexBuf.WriteString("CUSTOMWIDGET 'com.company.OldWidget' legacy1\n")
360+
indexBuf.WriteString("```\n")
361+
362+
indexPath := filepath.Join(docsDir, "_index.md")
363+
if err := os.WriteFile(indexPath, []byte(indexBuf.String()), 0644); err != nil {
364+
return fmt.Errorf("failed to write index: %w", err)
365+
}
366+
367+
fmt.Printf("Generated %d widget docs in %s\n", generated, docsDir)
368+
return nil
369+
}
370+
371+
func generateWidgetDoc(mpkDef *mpk.WidgetDefinition, mdlName string) string {
372+
var buf strings.Builder
373+
374+
prefix := "CUSTOMWIDGET"
375+
if mpkDef.IsPluggable {
376+
prefix = "PLUGGABLEWIDGET"
377+
}
378+
379+
buf.WriteString(fmt.Sprintf("# %s\n\n", mpkDef.Name))
380+
buf.WriteString(fmt.Sprintf("- **Widget ID:** `%s`\n", mpkDef.ID))
381+
buf.WriteString(fmt.Sprintf("- **Type:** %s\n", prefix))
382+
buf.WriteString(fmt.Sprintf("- **Version:** %s\n\n", mpkDef.Version))
383+
384+
buf.WriteString("## MDL Example\n\n```sql\n")
385+
buf.WriteString(fmt.Sprintf("%s '%s' widget1\n", prefix, mpkDef.ID))
386+
buf.WriteString("```\n\n")
387+
388+
if len(mpkDef.Properties) > 0 {
389+
buf.WriteString("## Properties\n\n")
390+
buf.WriteString("| Property | Type | Required | Default | Description |\n")
391+
buf.WriteString("|----------|------|----------|---------|-------------|\n")
392+
393+
for _, prop := range mpkDef.Properties {
394+
if prop.IsSystem {
395+
continue
396+
}
397+
req := ""
398+
if prop.Required {
399+
req = "Yes"
400+
}
401+
desc := prop.Description
402+
if len(desc) > 80 {
403+
desc = desc[:77] + "..."
404+
}
405+
buf.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %s |\n",
406+
prop.Key, prop.Type, req, prop.DefaultValue, desc))
407+
}
408+
}
409+
410+
buf.WriteString("\n")
411+
return buf.String()
412+
}
413+
190414
func runWidgetList(cmd *cobra.Command, args []string) error {
191415
registry, err := executor.NewWidgetRegistry()
192416
if err != nil {
@@ -207,10 +431,14 @@ func runWidgetList(cmd *cobra.Command, args []string) error {
207431
return nil
208432
}
209433

210-
fmt.Printf("%-20s %-50s %s\n", "MDL Name", "Widget ID", "Template")
211-
fmt.Printf("%-20s %-50s %s\n", strings.Repeat("-", 20), strings.Repeat("-", 50), strings.Repeat("-", 20))
434+
fmt.Printf("%-16s %-20s %-50s %s\n", "Kind", "MDL Name", "Widget ID", "Template")
435+
fmt.Printf("%-16s %-20s %-50s %s\n", strings.Repeat("-", 16), strings.Repeat("-", 20), strings.Repeat("-", 50), strings.Repeat("-", 20))
212436
for _, def := range defs {
213-
fmt.Printf("%-20s %-50s %s\n", def.MDLName, def.WidgetID, def.TemplateFile)
437+
kind := def.WidgetKind
438+
if kind == "" {
439+
kind = "pluggable"
440+
}
441+
fmt.Printf("%-16s %-20s %-50s %s\n", kind, def.MDLName, def.WidgetID, def.TemplateFile)
214442
}
215443
fmt.Printf("\nTotal: %d definitions\n", len(defs))
216444

0 commit comments

Comments
 (0)