Skip to content

Commit 5e85cc0

Browse files
engalarclaude
andcommitted
feat: pluggable widget engine v2
PLUGGABLEWIDGET syntax with explicit properties, auto datasource, auto child slots, TextTemplate attribute binding, and keyword properties. Core changes: - PLUGGABLEWIDGET 'widget.id' name (key: value) syntax - CUSTOMWIDGET/TABCONTAINER/TABPAGE grammar tokens - Auto datasource ordering (step 4.1, before child slots) - Auto child slot matching by container name - Object list auto-populate with Required filter - TextTemplate {AttrName} parameter binding - Widget lifecycle CLI (widget init/docs) - DESCRIBE PLUGGABLEWIDGET output for generic widgets - 20 new widget templates (mendix-11.6) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 37ce2db commit 5e85cc0

60 files changed

Lines changed: 44446 additions & 11662 deletions

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: 232 additions & 60 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,76 +140,219 @@ 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 {
120-
def := &executor.WidgetDefinition{
147+
widgetKind := "custom"
148+
if mpkDef.IsPluggable {
149+
widgetKind = "pluggable"
150+
}
151+
return &executor.WidgetDefinition{
121152
WidgetID: mpkDef.ID,
122153
MDLName: mdlName,
154+
WidgetKind: widgetKind,
123155
TemplateFile: strings.ToLower(mdlName) + ".json",
124156
DefaultEditable: "Always",
125157
}
158+
}
159+
160+
func runWidgetInit(cmd *cobra.Command, args []string) error {
161+
projectPath, _ := cmd.Flags().GetString("project")
162+
projectDir := filepath.Dir(projectPath)
163+
widgetsDir := filepath.Join(projectDir, "widgets")
164+
outputDir := filepath.Join(projectDir, ".mxcli", "widgets")
165+
166+
// Load built-in registry to skip widgets that already have hand-crafted definitions
167+
builtinRegistry, _ := executor.NewWidgetRegistry()
168+
169+
// Scan widgets/ for .mpk files
170+
matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk"))
171+
if err != nil {
172+
return fmt.Errorf("failed to scan widgets directory: %w", err)
173+
}
174+
if len(matches) == 0 {
175+
fmt.Println("No .mpk files found in widgets/ directory.")
176+
return nil
177+
}
178+
179+
if err := os.MkdirAll(outputDir, 0755); err != nil {
180+
return fmt.Errorf("failed to create output directory: %w", err)
181+
}
182+
183+
var extracted, skipped int
184+
for _, mpkPath := range matches {
185+
mpkDef, err := mpk.ParseMPK(mpkPath)
186+
if err != nil {
187+
log.Printf("warning: skipping %s: %v", filepath.Base(mpkPath), err)
188+
skipped++
189+
continue
190+
}
191+
192+
mdlName := deriveMDLName(mpkDef.ID)
193+
filename := strings.ToLower(mdlName) + ".def.json"
194+
outPath := filepath.Join(outputDir, filename)
126195

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 {
135-
case "attribute":
136-
mappings = append(mappings, executor.PropertyMapping{
137-
PropertyKey: prop.Key,
138-
Source: "Attribute",
139-
Operation: "attribute",
140-
})
141-
case "association":
142-
mappings = append(mappings, executor.PropertyMapping{
143-
PropertyKey: prop.Key,
144-
Source: "Association",
145-
Operation: "association",
146-
})
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"
196+
// Skip widgets that have hand-crafted built-in definitions (e.g., COMBOBOX, GALLERY)
197+
if builtinRegistry != nil {
198+
if _, ok := builtinRegistry.GetByWidgetID(mpkDef.ID); ok {
199+
skipped++
200+
continue
158201
}
159-
childSlots = append(childSlots, executor.ChildSlotMapping{
160-
PropertyKey: prop.Key,
161-
MDLContainer: containerName,
162-
Operation: "widgets",
163-
})
164-
case "selection":
165-
mappings = append(mappings, executor.PropertyMapping{
166-
PropertyKey: prop.Key,
167-
Source: "Selection",
168-
Operation: "selection",
169-
Default: prop.DefaultValue,
170-
})
171-
case "boolean", "string", "enumeration", "integer", "decimal":
172-
mapping := executor.PropertyMapping{
173-
PropertyKey: prop.Key,
174-
Operation: "primitive",
202+
}
203+
204+
// Skip if already exists on disk
205+
if _, err := os.Stat(outPath); err == nil {
206+
skipped++
207+
continue
208+
}
209+
210+
defJSON := generateDefJSON(mpkDef, mdlName)
211+
data, err := json.MarshalIndent(defJSON, "", " ")
212+
if err != nil {
213+
log.Printf("warning: skipping %s: %v", mpkDef.ID, err)
214+
skipped++
215+
continue
216+
}
217+
data = append(data, '\n')
218+
219+
if err := os.WriteFile(outPath, data, 0644); err != nil {
220+
return fmt.Errorf("failed to write %s: %w", outPath, err)
221+
}
222+
kind := "custom"
223+
if mpkDef.IsPluggable {
224+
kind = "pluggable"
225+
}
226+
fmt.Printf(" %-12s %-20s %s\n", kind, mdlName, mpkDef.ID)
227+
extracted++
228+
}
229+
230+
fmt.Printf("\nExtracted: %d, Skipped: %d (existing or unparseable)\n", extracted, skipped)
231+
232+
// Also generate docs
233+
fmt.Println("\nGenerating widget documentation...")
234+
return generateWidgetDocs(projectDir)
235+
}
236+
237+
func runWidgetDocs(cmd *cobra.Command, args []string) error {
238+
projectPath, _ := cmd.Flags().GetString("project")
239+
projectDir := filepath.Dir(projectPath)
240+
return generateWidgetDocs(projectDir)
241+
}
242+
243+
func generateWidgetDocs(projectDir string) error {
244+
widgetsDir := filepath.Join(projectDir, "widgets")
245+
docsDir := filepath.Join(projectDir, ".claude", "skills", "widgets")
246+
// Also try .ai-context
247+
if _, err := os.Stat(filepath.Join(projectDir, ".ai-context")); err == nil {
248+
docsDir = filepath.Join(projectDir, ".ai-context", "skills", "widgets")
249+
}
250+
251+
if err := os.MkdirAll(docsDir, 0755); err != nil {
252+
return fmt.Errorf("failed to create docs directory: %w", err)
253+
}
254+
255+
matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk"))
256+
if err != nil {
257+
return fmt.Errorf("failed to scan widgets directory: %w", err)
258+
}
259+
260+
var generated int
261+
var indexEntries []string
262+
263+
for _, mpkPath := range matches {
264+
mpkDef, err := mpk.ParseMPK(mpkPath)
265+
if err != nil {
266+
continue
267+
}
268+
269+
mdlName := deriveMDLName(mpkDef.ID)
270+
filename := strings.ToLower(mdlName) + ".md"
271+
outPath := filepath.Join(docsDir, filename)
272+
273+
doc := generateWidgetDoc(mpkDef, mdlName)
274+
275+
if err := os.WriteFile(outPath, []byte(doc), 0644); err != nil {
276+
log.Printf("warning: failed to write %s: %v", filename, err)
277+
continue
278+
}
279+
280+
kind := "CUSTOMWIDGET"
281+
if mpkDef.IsPluggable {
282+
kind = "PLUGGABLEWIDGET"
283+
}
284+
indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | %s | `%s` | %s | %d |",
285+
kind, mdlName, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties)))
286+
generated++
287+
}
288+
289+
// Write index
290+
var indexBuf strings.Builder
291+
indexBuf.WriteString("# Available Widgets\n\n")
292+
indexBuf.WriteString("Generated by `mxcli widget docs`. See individual files for property details.\n\n")
293+
indexBuf.WriteString("| Prefix | Name | Widget ID | Display Name | Props |\n")
294+
indexBuf.WriteString("|--------|------|-----------|--------------|-------|\n")
295+
for _, entry := range indexEntries {
296+
indexBuf.WriteString(entry)
297+
indexBuf.WriteString("\n")
298+
}
299+
indexBuf.WriteString("\n**Usage in MDL:**\n```sql\n")
300+
indexBuf.WriteString("-- React pluggable widgets\n")
301+
indexBuf.WriteString("PLUGGABLEWIDGET 'com.mendix.widget.custom.badge.Badge' badge1\n\n")
302+
indexBuf.WriteString("-- Legacy custom widgets\n")
303+
indexBuf.WriteString("CUSTOMWIDGET 'com.company.OldWidget' legacy1\n")
304+
indexBuf.WriteString("```\n")
305+
306+
indexPath := filepath.Join(docsDir, "_index.md")
307+
if err := os.WriteFile(indexPath, []byte(indexBuf.String()), 0644); err != nil {
308+
return fmt.Errorf("failed to write index: %w", err)
309+
}
310+
311+
fmt.Printf("Generated %d widget docs in %s\n", generated, docsDir)
312+
return nil
313+
}
314+
315+
func generateWidgetDoc(mpkDef *mpk.WidgetDefinition, mdlName string) string {
316+
var buf strings.Builder
317+
318+
prefix := "CUSTOMWIDGET"
319+
if mpkDef.IsPluggable {
320+
prefix = "PLUGGABLEWIDGET"
321+
}
322+
323+
buf.WriteString(fmt.Sprintf("# %s\n\n", mpkDef.Name))
324+
buf.WriteString(fmt.Sprintf("- **Widget ID:** `%s`\n", mpkDef.ID))
325+
buf.WriteString(fmt.Sprintf("- **Type:** %s\n", prefix))
326+
buf.WriteString(fmt.Sprintf("- **Version:** %s\n\n", mpkDef.Version))
327+
328+
buf.WriteString("## MDL Example\n\n```sql\n")
329+
buf.WriteString(fmt.Sprintf("%s '%s' widget1\n", prefix, mpkDef.ID))
330+
buf.WriteString("```\n\n")
331+
332+
if len(mpkDef.Properties) > 0 {
333+
buf.WriteString("## Properties\n\n")
334+
buf.WriteString("| Property | Type | Required | Default | Description |\n")
335+
buf.WriteString("|----------|------|----------|---------|-------------|\n")
336+
337+
for _, prop := range mpkDef.Properties {
338+
if prop.IsSystem {
339+
continue
340+
}
341+
req := ""
342+
if prop.Required {
343+
req = "Yes"
175344
}
176-
if prop.DefaultValue != "" {
177-
mapping.Value = prop.DefaultValue
345+
desc := prop.Description
346+
if len(desc) > 80 {
347+
desc = desc[:77] + "..."
178348
}
179-
mappings = append(mappings, mapping)
180-
// Skip action, expression, textTemplate, object, icon, image, file — too complex for auto-mapping
349+
buf.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %s |\n",
350+
prop.Key, prop.Type, req, prop.DefaultValue, desc))
181351
}
182352
}
183353

184-
def.PropertyMappings = mappings
185-
def.ChildSlots = childSlots
186-
187-
return def
354+
buf.WriteString("\n")
355+
return buf.String()
188356
}
189357

190358
func runWidgetList(cmd *cobra.Command, args []string) error {
@@ -207,10 +375,14 @@ func runWidgetList(cmd *cobra.Command, args []string) error {
207375
return nil
208376
}
209377

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))
378+
fmt.Printf("%-16s %-20s %-50s %s\n", "Kind", "MDL Name", "Widget ID", "Template")
379+
fmt.Printf("%-16s %-20s %-50s %s\n", strings.Repeat("-", 16), strings.Repeat("-", 20), strings.Repeat("-", 50), strings.Repeat("-", 20))
212380
for _, def := range defs {
213-
fmt.Printf("%-20s %-50s %s\n", def.MDLName, def.WidgetID, def.TemplateFile)
381+
kind := def.WidgetKind
382+
if kind == "" {
383+
kind = "pluggable"
384+
}
385+
fmt.Printf("%-16s %-20s %-50s %s\n", kind, def.MDLName, def.WidgetID, def.TemplateFile)
214386
}
215387
fmt.Printf("\nTotal: %d definitions\n", len(defs))
216388

0 commit comments

Comments
 (0)