Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion cmd/mxcli/cmd_bson_dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ Examples:

# Save dump to file
mxcli bson dump -p app.mpr --type page --object "PgTest.MyPage" > mypage.json

# Extract raw BSON baseline for roundtrip testing
mxcli bson dump -p app.mpr --type page --object "PgTest.MyPage" --format bson > mypage.mxunit
`,
Run: func(cmd *cobra.Command, args []string) {
projectPath, _ := cmd.Flags().GetString("project")
Expand Down Expand Up @@ -136,6 +139,12 @@ Examples:
os.Exit(1)
}

if format == "bson" {
// Write raw BSON bytes to stdout (for baseline extraction)
os.Stdout.Write(obj.Contents)
return
}

if format == "ndsl" {
var doc bson.D
if err := bson.Unmarshal(obj.Contents, &doc); err != nil {
Expand Down Expand Up @@ -342,5 +351,5 @@ func init() {
bsonDumpCmd.Flags().StringP("object", "o", "", "Object qualified name to dump (e.g., Module.PageName)")
bsonDumpCmd.Flags().BoolP("list", "l", false, "List all objects of the specified type")
bsonDumpCmd.Flags().StringSliceP("compare", "c", nil, "Compare two objects: --compare Obj1,Obj2")
bsonDumpCmd.Flags().String("format", "json", "Output format: json, ndsl")
bsonDumpCmd.Flags().String("format", "json", "Output format: json, ndsl, bson (raw bytes)")
}
292 changes: 232 additions & 60 deletions cmd/mxcli/cmd_widget.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,39 @@ var widgetListCmd = &cobra.Command{
RunE: runWidgetList,
}

var widgetInitCmd = &cobra.Command{
Use: "init",
Short: "Extract definitions for all project widgets",
Long: `Scan the project's widgets/ directory, extract .def.json for each .mpk,
and generate skill documentation in .claude/skills/widgets/.

This enables CREATE PAGE to use any project widget via the pluggable engine.`,
RunE: runWidgetInit,
}

var widgetDocsCmd = &cobra.Command{
Use: "docs",
Short: "Generate widget skill documentation",
Long: `Generate per-widget markdown documentation in .claude/skills/widgets/ from .mpk definitions.`,
RunE: runWidgetDocs,
}

func init() {
widgetExtractCmd.Flags().String("mpk", "", "Path to .mpk widget package file")
widgetExtractCmd.Flags().StringP("output", "o", "", "Output directory (default: .mxcli/widgets/)")
widgetExtractCmd.Flags().String("mdl-name", "", "Override the MDL keyword name (default: derived from widget name)")
widgetExtractCmd.MarkFlagRequired("mpk")

widgetInitCmd.Flags().StringP("project", "p", "", "Path to .mpr project file")
widgetInitCmd.MarkFlagRequired("project")

widgetDocsCmd.Flags().StringP("project", "p", "", "Path to .mpr project file")
widgetDocsCmd.MarkFlagRequired("project")

widgetCmd.AddCommand(widgetExtractCmd)
widgetCmd.AddCommand(widgetListCmd)
widgetCmd.AddCommand(widgetInitCmd)
widgetCmd.AddCommand(widgetDocsCmd)
rootCmd.AddCommand(widgetCmd)
}

Expand Down Expand Up @@ -115,76 +140,219 @@ func deriveMDLName(widgetID string) string {
return strings.ToUpper(name)
}

// generateDefJSON creates a WidgetDefinition from an mpk.WidgetDefinition.
// generateDefJSON creates a skeleton WidgetDefinition from an mpk.WidgetDefinition.
// Properties are handled explicitly from MDL via the engine's explicit property pass,
// so no propertyMappings or childSlots are generated here.
func generateDefJSON(mpkDef *mpk.WidgetDefinition, mdlName string) *executor.WidgetDefinition {
def := &executor.WidgetDefinition{
widgetKind := "custom"
if mpkDef.IsPluggable {
widgetKind = "pluggable"
}
return &executor.WidgetDefinition{
WidgetID: mpkDef.ID,
MDLName: mdlName,
WidgetKind: widgetKind,
TemplateFile: strings.ToLower(mdlName) + ".json",
DefaultEditable: "Always",
}
}

func runWidgetInit(cmd *cobra.Command, args []string) error {
projectPath, _ := cmd.Flags().GetString("project")
projectDir := filepath.Dir(projectPath)
widgetsDir := filepath.Join(projectDir, "widgets")
outputDir := filepath.Join(projectDir, ".mxcli", "widgets")

// Load built-in registry to skip widgets that already have hand-crafted definitions
builtinRegistry, _ := executor.NewWidgetRegistry()

// Scan widgets/ for .mpk files
matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk"))
if err != nil {
return fmt.Errorf("failed to scan widgets directory: %w", err)
}
if len(matches) == 0 {
fmt.Println("No .mpk files found in widgets/ directory.")
return nil
}

if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}

var extracted, skipped int
for _, mpkPath := range matches {
mpkDef, err := mpk.ParseMPK(mpkPath)
if err != nil {
log.Printf("warning: skipping %s: %v", filepath.Base(mpkPath), err)
skipped++
continue
}

mdlName := deriveMDLName(mpkDef.ID)
filename := strings.ToLower(mdlName) + ".def.json"
outPath := filepath.Join(outputDir, filename)

// Build property mappings by inferring operations from XML types
var mappings []executor.PropertyMapping
var childSlots []executor.ChildSlotMapping

for _, prop := range mpkDef.Properties {
normalizedType := mpk.NormalizeType(prop.Type)

switch normalizedType {
case "attribute":
mappings = append(mappings, executor.PropertyMapping{
PropertyKey: prop.Key,
Source: "Attribute",
Operation: "attribute",
})
case "association":
mappings = append(mappings, executor.PropertyMapping{
PropertyKey: prop.Key,
Source: "Association",
Operation: "association",
})
case "datasource":
mappings = append(mappings, executor.PropertyMapping{
PropertyKey: prop.Key,
Source: "DataSource",
Operation: "datasource",
})
case "widgets":
// Widgets properties become child slots
containerName := strings.ToUpper(prop.Key)
if containerName == "CONTENT" {
containerName = "TEMPLATE"
// Skip widgets that have hand-crafted built-in definitions (e.g., COMBOBOX, GALLERY)
if builtinRegistry != nil {
if _, ok := builtinRegistry.GetByWidgetID(mpkDef.ID); ok {
skipped++
continue
}
childSlots = append(childSlots, executor.ChildSlotMapping{
PropertyKey: prop.Key,
MDLContainer: containerName,
Operation: "widgets",
})
case "selection":
mappings = append(mappings, executor.PropertyMapping{
PropertyKey: prop.Key,
Source: "Selection",
Operation: "selection",
Default: prop.DefaultValue,
})
case "boolean", "string", "enumeration", "integer", "decimal":
mapping := executor.PropertyMapping{
PropertyKey: prop.Key,
Operation: "primitive",
}

// Skip if already exists on disk
if _, err := os.Stat(outPath); err == nil {
skipped++
continue
}

defJSON := generateDefJSON(mpkDef, mdlName)
data, err := json.MarshalIndent(defJSON, "", " ")
if err != nil {
log.Printf("warning: skipping %s: %v", mpkDef.ID, err)
skipped++
continue
}
data = append(data, '\n')

if err := os.WriteFile(outPath, data, 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", outPath, err)
}
kind := "custom"
if mpkDef.IsPluggable {
kind = "pluggable"
}
fmt.Printf(" %-12s %-20s %s\n", kind, mdlName, mpkDef.ID)
extracted++
}

fmt.Printf("\nExtracted: %d, Skipped: %d (existing or unparseable)\n", extracted, skipped)

// Also generate docs
fmt.Println("\nGenerating widget documentation...")
return generateWidgetDocs(projectDir)
}

func runWidgetDocs(cmd *cobra.Command, args []string) error {
projectPath, _ := cmd.Flags().GetString("project")
projectDir := filepath.Dir(projectPath)
return generateWidgetDocs(projectDir)
}

func generateWidgetDocs(projectDir string) error {
widgetsDir := filepath.Join(projectDir, "widgets")
docsDir := filepath.Join(projectDir, ".claude", "skills", "widgets")
// Also try .ai-context
if _, err := os.Stat(filepath.Join(projectDir, ".ai-context")); err == nil {
docsDir = filepath.Join(projectDir, ".ai-context", "skills", "widgets")
}

if err := os.MkdirAll(docsDir, 0755); err != nil {
return fmt.Errorf("failed to create docs directory: %w", err)
}

matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk"))
if err != nil {
return fmt.Errorf("failed to scan widgets directory: %w", err)
}

var generated int
var indexEntries []string

for _, mpkPath := range matches {
mpkDef, err := mpk.ParseMPK(mpkPath)
if err != nil {
continue
}

mdlName := deriveMDLName(mpkDef.ID)
filename := strings.ToLower(mdlName) + ".md"
outPath := filepath.Join(docsDir, filename)

doc := generateWidgetDoc(mpkDef, mdlName)

if err := os.WriteFile(outPath, []byte(doc), 0644); err != nil {
log.Printf("warning: failed to write %s: %v", filename, err)
continue
}

kind := "CUSTOMWIDGET"
if mpkDef.IsPluggable {
kind = "PLUGGABLEWIDGET"
}
indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | %s | `%s` | %s | %d |",
kind, mdlName, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties)))
generated++
}

// Write index
var indexBuf strings.Builder
indexBuf.WriteString("# Available Widgets\n\n")
indexBuf.WriteString("Generated by `mxcli widget docs`. See individual files for property details.\n\n")
indexBuf.WriteString("| Prefix | Name | Widget ID | Display Name | Props |\n")
indexBuf.WriteString("|--------|------|-----------|--------------|-------|\n")
for _, entry := range indexEntries {
indexBuf.WriteString(entry)
indexBuf.WriteString("\n")
}
indexBuf.WriteString("\n**Usage in MDL:**\n```sql\n")
indexBuf.WriteString("-- React pluggable widgets\n")
indexBuf.WriteString("PLUGGABLEWIDGET 'com.mendix.widget.custom.badge.Badge' badge1\n\n")
indexBuf.WriteString("-- Legacy custom widgets\n")
indexBuf.WriteString("CUSTOMWIDGET 'com.company.OldWidget' legacy1\n")
indexBuf.WriteString("```\n")

indexPath := filepath.Join(docsDir, "_index.md")
if err := os.WriteFile(indexPath, []byte(indexBuf.String()), 0644); err != nil {
return fmt.Errorf("failed to write index: %w", err)
}

fmt.Printf("Generated %d widget docs in %s\n", generated, docsDir)
return nil
}

func generateWidgetDoc(mpkDef *mpk.WidgetDefinition, mdlName string) string {
var buf strings.Builder

prefix := "CUSTOMWIDGET"
if mpkDef.IsPluggable {
prefix = "PLUGGABLEWIDGET"
}

buf.WriteString(fmt.Sprintf("# %s\n\n", mpkDef.Name))
buf.WriteString(fmt.Sprintf("- **Widget ID:** `%s`\n", mpkDef.ID))
buf.WriteString(fmt.Sprintf("- **Type:** %s\n", prefix))
buf.WriteString(fmt.Sprintf("- **Version:** %s\n\n", mpkDef.Version))

buf.WriteString("## MDL Example\n\n```sql\n")
buf.WriteString(fmt.Sprintf("%s '%s' widget1\n", prefix, mpkDef.ID))
buf.WriteString("```\n\n")

if len(mpkDef.Properties) > 0 {
buf.WriteString("## Properties\n\n")
buf.WriteString("| Property | Type | Required | Default | Description |\n")
buf.WriteString("|----------|------|----------|---------|-------------|\n")

for _, prop := range mpkDef.Properties {
if prop.IsSystem {
continue
}
req := ""
if prop.Required {
req = "Yes"
}
if prop.DefaultValue != "" {
mapping.Value = prop.DefaultValue
desc := prop.Description
if len(desc) > 80 {
desc = desc[:77] + "..."
}
mappings = append(mappings, mapping)
// Skip action, expression, textTemplate, object, icon, image, file — too complex for auto-mapping
buf.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %s |\n",
prop.Key, prop.Type, req, prop.DefaultValue, desc))
}
}

def.PropertyMappings = mappings
def.ChildSlots = childSlots

return def
buf.WriteString("\n")
return buf.String()
}

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

fmt.Printf("%-20s %-50s %s\n", "MDL Name", "Widget ID", "Template")
fmt.Printf("%-20s %-50s %s\n", strings.Repeat("-", 20), strings.Repeat("-", 50), strings.Repeat("-", 20))
fmt.Printf("%-16s %-20s %-50s %s\n", "Kind", "MDL Name", "Widget ID", "Template")
fmt.Printf("%-16s %-20s %-50s %s\n", strings.Repeat("-", 16), strings.Repeat("-", 20), strings.Repeat("-", 50), strings.Repeat("-", 20))
for _, def := range defs {
fmt.Printf("%-20s %-50s %s\n", def.MDLName, def.WidgetID, def.TemplateFile)
kind := def.WidgetKind
if kind == "" {
kind = "pluggable"
}
fmt.Printf("%-16s %-20s %-50s %s\n", kind, def.MDLName, def.WidgetID, def.TemplateFile)
}
fmt.Printf("\nTotal: %d definitions\n", len(defs))

Expand Down
Loading
Loading