Compare commits
8 Commits
main
...
d36e1bfd86
Author | SHA1 | Date | |
---|---|---|---|
d36e1bfd86 | |||
cf3ad736b7 | |||
e71b1c3a23 | |||
4ac93ee924 | |||
1ee8de23da | |||
da43647b54 | |||
e28b6c89ef | |||
e9a422ef07 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/masonry.exe
|
42
.idea/copilotDiffState.xml
generated
Normal file
42
.idea/copilotDiffState.xml
generated
Normal file
File diff suppressed because one or more lines are too long
@ -14,6 +14,7 @@ func main() {
|
|||||||
tailwindCmd(),
|
tailwindCmd(),
|
||||||
setupCmd(),
|
setupCmd(),
|
||||||
vueGenCmd(),
|
vueGenCmd(),
|
||||||
|
serveCmd(), // New command for server interpreter
|
||||||
}
|
}
|
||||||
|
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
|
@ -11,9 +11,15 @@ import (
|
|||||||
vue_gen "masonry/vue-gen"
|
vue_gen "masonry/vue-gen"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/alecthomas/participle/v2"
|
||||||
|
|
||||||
|
"masonry/interpreter"
|
||||||
|
"masonry/lang"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/proto/application.proto.tmpl
|
//go:embed templates/proto/application.proto.tmpl
|
||||||
@ -156,104 +162,143 @@ func generateCmd() *cli.Command {
|
|||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: "generate",
|
Name: "generate",
|
||||||
Aliases: []string{"g"},
|
Aliases: []string{"g"},
|
||||||
Usage: "Generate code from proto files",
|
Usage: "Generate code from proto files or Masonry files",
|
||||||
Category: "generator",
|
Category: "generator",
|
||||||
Description: "This command will generate code from the proto files in the proto directory and place them in a language folder in the gen folder.",
|
Description: "This command will generate code from proto files or convert Masonry files to various formats.",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "proto",
|
||||||
|
Usage: "Generate code from proto files",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return generateProtoCode()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "html",
|
||||||
|
Usage: "Generate HTML from Masonry files",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "input",
|
||||||
|
Usage: "Input Masonry file path",
|
||||||
|
Required: true,
|
||||||
|
Aliases: []string{"i"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "output",
|
||||||
|
Usage: "Output directory for generated HTML files",
|
||||||
|
Value: "./output",
|
||||||
|
Aliases: []string{"o"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
inputFile := c.String("input")
|
||||||
|
outputDir := c.String("output")
|
||||||
|
|
||||||
|
return generateHTML(inputFile, outputDir)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
fmt.Println("Generating code...")
|
// Default action - generate proto code for backward compatibility
|
||||||
|
return generateProtoCode()
|
||||||
protocArgs := []string{
|
|
||||||
"-I",
|
|
||||||
".",
|
|
||||||
"--go_out",
|
|
||||||
"gen/go",
|
|
||||||
"--go-grpc_out",
|
|
||||||
"gen/go",
|
|
||||||
"--go-grpc_opt=require_unimplemented_servers=false",
|
|
||||||
"--gorm_out",
|
|
||||||
"gen/go",
|
|
||||||
"--grpc-gateway_out",
|
|
||||||
"gen/go",
|
|
||||||
"--grpc-gateway_opt",
|
|
||||||
"logtostderr=true",
|
|
||||||
"--openapiv2_out",
|
|
||||||
"gen/openapi",
|
|
||||||
"--openapiv2_opt",
|
|
||||||
"logtostderr=true",
|
|
||||||
"--proto_path=./proto_include",
|
|
||||||
"proto/*.proto",
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate go code
|
|
||||||
cmd := exec.Command(
|
|
||||||
"protoc",
|
|
||||||
protocArgs...,
|
|
||||||
)
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
var buff bytes.Buffer
|
|
||||||
cmd.Stderr = &buff
|
|
||||||
fmt.Println(buff.String())
|
|
||||||
|
|
||||||
return fmt.Errorf("error generating go code | %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate ts code
|
|
||||||
// if webapp folder is present, generate typescript code
|
|
||||||
if _, err := os.Stat("webapp"); err == nil {
|
|
||||||
err = os.Chdir("webapp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error changing directory to webapp | %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd = exec.Command("npx",
|
|
||||||
"openapi-typescript-codegen",
|
|
||||||
"--input",
|
|
||||||
"../gen/openapi/proto/service.swagger.json",
|
|
||||||
"--output",
|
|
||||||
"src/generated",
|
|
||||||
"--client",
|
|
||||||
"fetch",
|
|
||||||
)
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
err = cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error generating typescript code | %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure src/generated-sample-components exists
|
|
||||||
err = os.Mkdir("src/generated-sample-components", 0755)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error creating src/generated-components directory | %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate vue components
|
|
||||||
err = vue_gen.GenVueFromSwagger("../gen/openapi/proto/service.swagger.json", "src/generated-sample-components")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error generating vue components | %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("npm", "install", "@masonitestudios/dynamic-vue")
|
|
||||||
err = cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error installing @masonitestudios/dynamic-vue | %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Chdir("..")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error changing directory back to root | %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateProtoCode handles the original proto code generation logic
|
||||||
|
func generateProtoCode() error {
|
||||||
|
fmt.Println("Generating code...")
|
||||||
|
|
||||||
|
protocArgs := []string{
|
||||||
|
"-I",
|
||||||
|
".",
|
||||||
|
"--go_out",
|
||||||
|
"gen/go",
|
||||||
|
"--go-grpc_out",
|
||||||
|
"gen/go",
|
||||||
|
"--go-grpc_opt=require_unimplemented_servers=false",
|
||||||
|
"--gorm_out",
|
||||||
|
"gen/go",
|
||||||
|
"--grpc-gateway_out",
|
||||||
|
"gen/go",
|
||||||
|
"--grpc-gateway_opt",
|
||||||
|
"logtostderr=true",
|
||||||
|
"--openapiv2_out",
|
||||||
|
"gen/openapi",
|
||||||
|
"--openapiv2_opt",
|
||||||
|
"logtostderr=true",
|
||||||
|
"--proto_path=./proto_include",
|
||||||
|
"proto/*.proto",
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate go code
|
||||||
|
cmd := exec.Command(
|
||||||
|
"protoc",
|
||||||
|
protocArgs...,
|
||||||
|
)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
var buff bytes.Buffer
|
||||||
|
cmd.Stderr = &buff
|
||||||
|
fmt.Println(buff.String())
|
||||||
|
|
||||||
|
return fmt.Errorf("error generating go code | %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ts code
|
||||||
|
// if webapp folder is present, generate typescript code
|
||||||
|
if _, err := os.Stat("webapp"); err == nil {
|
||||||
|
err = os.Chdir("webapp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error changing directory to webapp | %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("npx",
|
||||||
|
"openapi-typescript-codegen",
|
||||||
|
"--input",
|
||||||
|
"../gen/openapi/proto/service.swagger.json",
|
||||||
|
"--output",
|
||||||
|
"src/generated",
|
||||||
|
"--client",
|
||||||
|
"fetch",
|
||||||
|
)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
err = cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error generating typescript code | %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure src/generated-sample-components exists
|
||||||
|
err = os.Mkdir("src/generated-sample-components", 0755)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating src/generated-components directory | %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate vue components
|
||||||
|
err = vue_gen.GenVueFromSwagger("../gen/openapi/proto/service.swagger.json", "src/generated-sample-components")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error generating vue components | %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("npm", "install", "@masonitestudios/dynamic-vue")
|
||||||
|
err = cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error installing @masonitestudios/dynamic-vue | %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Chdir("..")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error changing directory back to root | %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func webappCmd() *cli.Command {
|
func webappCmd() *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: "webapp",
|
Name: "webapp",
|
||||||
@ -368,3 +413,162 @@ func vueGenCmd() *cli.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateHTML parses a Masonry file and generates HTML output
|
||||||
|
func generateHTML(inputFile, outputDir string) error {
|
||||||
|
fmt.Printf("Generating HTML from %s to %s\n", inputFile, outputDir)
|
||||||
|
|
||||||
|
// Read the input file
|
||||||
|
content, err := os.ReadFile(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read input file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parser
|
||||||
|
parser, err := participle.Build[lang.AST]()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create parser: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the Masonry file
|
||||||
|
ast, err := parser.ParseString(inputFile, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse Masonry file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTML interpreter
|
||||||
|
htmlInterpreter := interpreter.NewHTMLInterpreter()
|
||||||
|
|
||||||
|
// Generate HTML
|
||||||
|
htmlFiles, err := htmlInterpreter.GenerateHTML(ast)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate HTML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory
|
||||||
|
err = os.MkdirAll(outputDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write HTML files
|
||||||
|
for filename, content := range htmlFiles {
|
||||||
|
outputPath := filepath.Join(outputDir, filename)
|
||||||
|
err = os.WriteFile(outputPath, []byte(content), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write file %s: %w", outputPath, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Generated: %s\n", outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully generated %d HTML file(s)\n", len(htmlFiles))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveCmd() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "serve",
|
||||||
|
Usage: "Generate and run a simple HTTP server from a Masonry file",
|
||||||
|
Description: "This command parses a Masonry file and generates a simple Go HTTP server with in-memory database.",
|
||||||
|
Category: "development",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "file",
|
||||||
|
Usage: "Path to the Masonry file to interpret",
|
||||||
|
Required: true,
|
||||||
|
Aliases: []string{"f"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "output",
|
||||||
|
Usage: "Output file for the generated server code",
|
||||||
|
Value: "server.go",
|
||||||
|
Aliases: []string{"o"},
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "run",
|
||||||
|
Usage: "Run the server after generating it",
|
||||||
|
Value: false,
|
||||||
|
Aliases: []string{"r"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
masonryFile := c.String("file")
|
||||||
|
outputFile := c.String("output")
|
||||||
|
shouldRun := c.Bool("run")
|
||||||
|
|
||||||
|
fmt.Printf("Parsing Masonry file: %s\n", masonryFile)
|
||||||
|
|
||||||
|
// Read the Masonry file
|
||||||
|
content, err := os.ReadFile(masonryFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading Masonry file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the Masonry file
|
||||||
|
parser, err := participle.Build[lang.AST](
|
||||||
|
participle.Unquote("String"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error building parser: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ast, err := parser.ParseString("", string(content))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing Masonry file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate server code using the server interpreter
|
||||||
|
serverInterpreter := interpreter.NewServerInterpreter()
|
||||||
|
serverCode, err := serverInterpreter.Interpret(*ast)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error interpreting Masonry file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the generated server code to the output file
|
||||||
|
err = os.WriteFile(outputFile, []byte(serverCode), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error writing server code to file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Server code generated successfully: %s\n", outputFile)
|
||||||
|
|
||||||
|
if shouldRun {
|
||||||
|
fmt.Println("Installing dependencies...")
|
||||||
|
|
||||||
|
// Initialize go module if it doesn't exist
|
||||||
|
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
||||||
|
cmd := exec.Command("go", "mod", "init", "masonry-server")
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("error initializing go module: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install required dependencies
|
||||||
|
dependencies := []string{
|
||||||
|
"github.com/google/uuid",
|
||||||
|
"github.com/gorilla/mux",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
fmt.Printf("Installing %s...\n", dep)
|
||||||
|
cmd := exec.Command("go", "get", dep)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("error installing dependency %s: %w", dep, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Running server from %s...\n", outputFile)
|
||||||
|
cmd := exec.Command("go", "run", outputFile)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
148
examples/lang/debug.go
Normal file
148
examples/lang/debug.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"masonry/lang"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Read the example.masonry file from the correct path
|
||||||
|
content, err := os.ReadFile("examples/lang/example.masonry")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading example.masonry: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := string(content)
|
||||||
|
|
||||||
|
// Try to parse the DSL
|
||||||
|
ast, err := lang.ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ Parse Error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, parsing was successful!
|
||||||
|
fmt.Printf("🎉 Successfully parsed DSL with block delimiters!\n\n")
|
||||||
|
|
||||||
|
// Count what we parsed
|
||||||
|
var servers, entities, endpoints, pages int
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Server != nil {
|
||||||
|
servers++
|
||||||
|
}
|
||||||
|
if def.Entity != nil {
|
||||||
|
entities++
|
||||||
|
}
|
||||||
|
if def.Endpoint != nil {
|
||||||
|
endpoints++
|
||||||
|
}
|
||||||
|
if def.Page != nil {
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📊 Parsing Summary:\n")
|
||||||
|
fmt.Printf(" Servers: %d\n", servers)
|
||||||
|
fmt.Printf(" Entities: %d\n", entities)
|
||||||
|
fmt.Printf(" Endpoints: %d\n", endpoints)
|
||||||
|
fmt.Printf(" Pages: %d\n", pages)
|
||||||
|
fmt.Printf(" Total Definitions: %d\n", len(ast.Definitions))
|
||||||
|
|
||||||
|
// Verify key structures parsed correctly
|
||||||
|
fmt.Printf("\n✅ Validation Results:\n")
|
||||||
|
|
||||||
|
// Check server has settings in block
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Server != nil {
|
||||||
|
if len(def.Server.Settings) > 0 {
|
||||||
|
fmt.Printf(" ✓ Server '%s' has %d settings (block syntax working)\n", def.Server.Name, len(def.Server.Settings))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check entities have fields in blocks
|
||||||
|
entityCount := 0
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Entity != nil {
|
||||||
|
entityCount++
|
||||||
|
if len(def.Entity.Fields) > 0 {
|
||||||
|
fmt.Printf(" ✓ Entity '%s' has %d fields (block syntax working)\n", def.Entity.Name, len(def.Entity.Fields))
|
||||||
|
}
|
||||||
|
if entityCount >= 2 { // Just show first couple
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check endpoints have params in blocks
|
||||||
|
endpointCount := 0
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Endpoint != nil {
|
||||||
|
endpointCount++
|
||||||
|
if len(def.Endpoint.Params) > 0 {
|
||||||
|
fmt.Printf(" ✓ Endpoint '%s %s' has %d params (block syntax working)\n", def.Endpoint.Method, def.Endpoint.Path, len(def.Endpoint.Params))
|
||||||
|
}
|
||||||
|
if endpointCount >= 2 { // Just show first couple
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pages have content in blocks
|
||||||
|
pageCount := 0
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Page != nil {
|
||||||
|
pageCount++
|
||||||
|
totalContent := len(def.Page.Meta) + len(def.Page.Sections) + len(def.Page.Components)
|
||||||
|
if totalContent > 0 {
|
||||||
|
fmt.Printf(" ✓ Page '%s' has %d content items (block syntax working)\n", def.Page.Name, totalContent)
|
||||||
|
}
|
||||||
|
if pageCount >= 2 { // Just show first couple
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for nested sections (complex structures)
|
||||||
|
var totalSections, nestedSections int
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Page != nil {
|
||||||
|
totalSections += len(def.Page.Sections)
|
||||||
|
for _, section := range def.Page.Sections {
|
||||||
|
nestedSections += countNestedSections(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalSections > 0 {
|
||||||
|
fmt.Printf(" ✓ Found %d sections with %d nested levels (recursive parsing working)\n", totalSections, nestedSections)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n🎯 Block delimiter syntax is working correctly!\n")
|
||||||
|
fmt.Printf(" All constructs (server, entity, endpoint, page, section, component) now use { } blocks\n")
|
||||||
|
fmt.Printf(" No more ambiguous whitespace-dependent parsing\n")
|
||||||
|
fmt.Printf(" Language is now unambiguous and consistent\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to count nested sections recursively
|
||||||
|
func countNestedSections(section lang.Section) int {
|
||||||
|
count := 0
|
||||||
|
for _, element := range section.Elements {
|
||||||
|
if element.Section != nil {
|
||||||
|
count++
|
||||||
|
count += countNestedSections(*element.Section)
|
||||||
|
}
|
||||||
|
if element.Component != nil {
|
||||||
|
for _, compElement := range element.Component.Elements {
|
||||||
|
if compElement.Section != nil {
|
||||||
|
count++
|
||||||
|
count += countNestedSections(*compElement.Section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
295
examples/lang/example.masonry
Normal file
295
examples/lang/example.masonry
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
// Enhanced Masonry DSL example demonstrating simplified unified structure
|
||||||
|
// This shows how containers, tabs, panels, modals, and master-detail are now unified as sections
|
||||||
|
|
||||||
|
// Server configuration
|
||||||
|
server MyApp {
|
||||||
|
host "localhost"
|
||||||
|
port 8080
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity definitions with various field types and relationships
|
||||||
|
entity User desc "User account management" {
|
||||||
|
id: uuid required unique
|
||||||
|
email: string required validate email validate min_length "5"
|
||||||
|
name: string default "Anonymous"
|
||||||
|
created_at: timestamp default "now()"
|
||||||
|
profile_id: uuid relates to Profile as one via "user_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
entity Profile desc "User profile information" {
|
||||||
|
id: uuid required unique
|
||||||
|
user_id: uuid required relates to User as one
|
||||||
|
bio: text validate max_length "500"
|
||||||
|
avatar_url: string validate url
|
||||||
|
updated_at: timestamp
|
||||||
|
posts: uuid relates to Post as many
|
||||||
|
}
|
||||||
|
|
||||||
|
entity Post desc "Blog posts" {
|
||||||
|
id: uuid required unique
|
||||||
|
title: string required validate min_length "1" validate max_length "200"
|
||||||
|
content: text required
|
||||||
|
author_id: uuid required relates to User as one
|
||||||
|
published: boolean default "false"
|
||||||
|
created_at: timestamp default "now()"
|
||||||
|
tags: uuid relates to Tag as many through "post_tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
entity Tag desc "Content tags" {
|
||||||
|
id: uuid required unique
|
||||||
|
name: string required unique validate min_length "1" validate max_length "50"
|
||||||
|
slug: string required unique indexed
|
||||||
|
created_at: timestamp default "now()"
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Endpoints with different HTTP methods and parameter sources
|
||||||
|
endpoint GET "/users" for User desc "List users" auth {
|
||||||
|
param page: int from query
|
||||||
|
param limit: int required from query
|
||||||
|
returns list as "json" fields [id, email, name]
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint POST "/users" for User desc "Create user" {
|
||||||
|
param user_data: object required from body
|
||||||
|
returns object as "json" fields [id, email, name]
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint PUT "/users/{id}" for User desc "Update user" {
|
||||||
|
param id: uuid required from path
|
||||||
|
param user_data: object required from body
|
||||||
|
returns object
|
||||||
|
custom "update_user_logic"
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint DELETE "/users/{id}" for User desc "Delete user" auth {
|
||||||
|
param id: uuid required from path
|
||||||
|
returns object
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint GET "/posts" for Post desc "List posts" {
|
||||||
|
param author_id: uuid from query
|
||||||
|
param published: boolean from query
|
||||||
|
param page: int from query
|
||||||
|
returns list as "json" fields [id, title, author_id, published]
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint POST "/posts" for Post desc "Create post" auth {
|
||||||
|
param post_data: object required from body
|
||||||
|
returns object fields [id, title, content, author_id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced User Management page with unified section layout
|
||||||
|
page UserManagement at "/admin/users" layout AdminLayout title "User Management" auth {
|
||||||
|
meta description "Manage system users"
|
||||||
|
meta keywords "users, admin, management"
|
||||||
|
|
||||||
|
section main type container class "grid grid-cols-3 gap-4" {
|
||||||
|
section sidebar class "col-span-1" {
|
||||||
|
component UserStats for User {
|
||||||
|
data from "/users/stats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section content class "col-span-2" {
|
||||||
|
component UserTable for User {
|
||||||
|
fields [email, name, role, created_at]
|
||||||
|
actions [edit, delete, view]
|
||||||
|
data from "/users"
|
||||||
|
}
|
||||||
|
|
||||||
|
section editPanel type panel trigger "edit" position "slide-right" for User {
|
||||||
|
component UserForm for User {
|
||||||
|
field email type text label "Email" required
|
||||||
|
field name type text label "Name" required
|
||||||
|
field role type select options ["admin", "user"]
|
||||||
|
button save label "Save User" style "primary" via "/users/{id}"
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced Form component with detailed field configurations
|
||||||
|
page UserFormPage at "/admin/users/new" layout AdminLayout title "Create User" auth {
|
||||||
|
component Form for User {
|
||||||
|
field email type text label "Email Address" placeholder "Enter your email" required validate email
|
||||||
|
field name type text label "Full Name" placeholder "Enter your full name" required
|
||||||
|
field role type select label "User Role" options ["admin", "user", "moderator"] default "user"
|
||||||
|
field avatar type file label "Profile Picture" accept "image/*"
|
||||||
|
field bio type textarea label "Biography" placeholder "Tell us about yourself" rows 4
|
||||||
|
|
||||||
|
when role equals "admin" {
|
||||||
|
component AdminPermissions {
|
||||||
|
field permissions type multiselect label "Permissions" {
|
||||||
|
options ["users.manage", "posts.manage", "system.config"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section actions {
|
||||||
|
component ActionButtons {
|
||||||
|
button save label "Save User" style "primary" loading "Saving..." via "/users"
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard with tabbed interface using unified sections
|
||||||
|
page Dashboard at "/dashboard" layout MainLayout title "Dashboard" {
|
||||||
|
section tabs type container {
|
||||||
|
section overview type tab label "Overview" active {
|
||||||
|
component StatsCards
|
||||||
|
component RecentActivity
|
||||||
|
}
|
||||||
|
|
||||||
|
section users type tab label "Users" {
|
||||||
|
component UserTable for User {
|
||||||
|
data from "/users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section posts type tab label "Posts" {
|
||||||
|
component PostTable for Post {
|
||||||
|
data from "/posts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section createUserModal type modal trigger "create-user" {
|
||||||
|
component UserForm for User {
|
||||||
|
field email type text label "Email" required
|
||||||
|
field name type text label "Name" required
|
||||||
|
button save label "Create" via "/users"
|
||||||
|
button cancel label "Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post Management with master-detail layout using unified sections
|
||||||
|
page PostManagement at "/admin/posts" layout AdminLayout title "Post Management" auth {
|
||||||
|
section master type master {
|
||||||
|
component PostTable for Post {
|
||||||
|
field title type text label "Title" sortable
|
||||||
|
field author type relation label "Author" display "name" relates to User
|
||||||
|
field status type badge label "Status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section detail type detail trigger "edit" {
|
||||||
|
component PostForm for Post {
|
||||||
|
section basic class "mb-4" {
|
||||||
|
component BasicFields {
|
||||||
|
field title type text label "Post Title" required
|
||||||
|
field content type richtext label "Content" required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section metadata class "grid grid-cols-2 gap-4" {
|
||||||
|
component MetadataFields {
|
||||||
|
field author_id type autocomplete label "Author" {
|
||||||
|
source "/users" display "name" value "id"
|
||||||
|
}
|
||||||
|
field published type toggle label "Published" default "false"
|
||||||
|
field tags type multiselect label "Tags" {
|
||||||
|
source "/tags" display "name" value "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple table component with smart defaults
|
||||||
|
page SimpleUserList at "/users" layout MainLayout title "Users" {
|
||||||
|
component SimpleTable for User {
|
||||||
|
fields [email, name, created_at]
|
||||||
|
actions [edit, delete]
|
||||||
|
data from "/users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed table with simplified component attributes
|
||||||
|
page DetailedUserList at "/admin/users/detailed" layout AdminLayout title "Detailed User Management" auth {
|
||||||
|
component DetailedTable for User {
|
||||||
|
data from "/users"
|
||||||
|
pagination size 20
|
||||||
|
field email type text label "Email Address"
|
||||||
|
field name type text label "Full Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex nested sections example
|
||||||
|
page ComplexLayout at "/complex" layout MainLayout title "Complex Layout" {
|
||||||
|
section mainContainer type container class "flex h-screen" {
|
||||||
|
section sidebar type container class "w-64 bg-gray-100" {
|
||||||
|
section navigation {
|
||||||
|
component NavMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
section userInfo type panel trigger "profile" position "bottom" {
|
||||||
|
component UserProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section content type container class "flex-1" {
|
||||||
|
section header class "h-16 border-b" {
|
||||||
|
component PageHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
section body class "flex-1 p-4" {
|
||||||
|
section tabs type container {
|
||||||
|
section overview type tab label "Overview" active {
|
||||||
|
section metrics class "grid grid-cols-3 gap-4" {
|
||||||
|
component MetricCard
|
||||||
|
component MetricCard
|
||||||
|
component MetricCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section details type tab label "Details" {
|
||||||
|
component DetailView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional rendering with sections and components
|
||||||
|
page ConditionalForm at "/conditional" layout MainLayout title "Conditional Form" {
|
||||||
|
component UserForm for User {
|
||||||
|
field email type text label "Email" required
|
||||||
|
field role type select options ["admin", "user", "moderator"]
|
||||||
|
|
||||||
|
when role equals "admin" {
|
||||||
|
section adminSection class "border-l-4 border-red-500 pl-4" {
|
||||||
|
component AdminPermissions {
|
||||||
|
field permissions type multiselect label "Admin Permissions" {
|
||||||
|
options ["users.manage", "posts.manage", "system.config"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component AdminSettings {
|
||||||
|
field max_users type number label "Max Users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when role equals "moderator" {
|
||||||
|
component ModeratorSettings {
|
||||||
|
field moderation_level type select label "Moderation Level" {
|
||||||
|
options ["basic", "advanced", "full"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section actions {
|
||||||
|
component ActionButtons {
|
||||||
|
button save label "Save User" style "primary" loading "Saving..."
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
examples/lang/sample.masonry
Normal file
61
examples/lang/sample.masonry
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// Sample Masonry application demonstrating the language features
|
||||||
|
entity User {
|
||||||
|
name: string required
|
||||||
|
email: string required unique
|
||||||
|
age: number
|
||||||
|
role: string default "user"
|
||||||
|
password: string required
|
||||||
|
}
|
||||||
|
|
||||||
|
page UserDashboard at "/dashboard" layout main title "User Dashboard" {
|
||||||
|
meta description "User management dashboard"
|
||||||
|
meta keywords "user, dashboard, management"
|
||||||
|
|
||||||
|
section header type container class "header" {
|
||||||
|
component nav {
|
||||||
|
field logo type text value "MyApp"
|
||||||
|
button profile label "Profile"
|
||||||
|
button logout label "Logout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section content type tab {
|
||||||
|
section users label "Users" active {
|
||||||
|
component table for User {
|
||||||
|
field name type text label "Full Name" sortable searchable
|
||||||
|
field email type email label "Email Address" sortable
|
||||||
|
field role type select options ["admin", "user", "moderator"]
|
||||||
|
button edit label "Edit"
|
||||||
|
button delete label "Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section settings label "Settings" {
|
||||||
|
component form for User {
|
||||||
|
field name type text label "Full Name" required placeholder "Enter your name"
|
||||||
|
field email type email label "Email" required placeholder "Enter your email"
|
||||||
|
field age type number label "Age" placeholder "Enter your age"
|
||||||
|
field role type select label "Role" options ["admin", "user", "moderator"] default "user"
|
||||||
|
field password type password label "Password" required
|
||||||
|
|
||||||
|
when role equals "admin" {
|
||||||
|
field permissions type select label "Admin Permissions" options ["read", "write", "delete"]
|
||||||
|
}
|
||||||
|
|
||||||
|
button save label "Save Changes"
|
||||||
|
button cancel label "Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section profile label "Profile" {
|
||||||
|
component form {
|
||||||
|
field avatar type file label "Profile Picture" accept ".jpg,.png"
|
||||||
|
field bio type textarea label "Biography" rows 4 placeholder "Tell us about yourself"
|
||||||
|
field theme type select label "Theme" options ["light", "dark"] default "light"
|
||||||
|
field notifications type checkbox label "Enable Notifications" default "true"
|
||||||
|
|
||||||
|
button update label "Update Profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
go.mod
1
go.mod
@ -3,6 +3,7 @@ module masonry
|
|||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alecthomas/participle/v2 v2.1.4
|
||||||
github.com/urfave/cli/v2 v2.27.5
|
github.com/urfave/cli/v2 v2.27.5
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.22.0
|
||||||
)
|
)
|
||||||
|
8
go.sum
8
go.sum
@ -1,5 +1,13 @@
|
|||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
||||||
|
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
||||||
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||||
|
707
interpreter/html_interpreter.go
Normal file
707
interpreter/html_interpreter.go
Normal file
@ -0,0 +1,707 @@
|
|||||||
|
package interpreter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"masonry/lang"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTMLInterpreter converts Masonry AST to HTML/JavaScript
|
||||||
|
type HTMLInterpreter struct {
|
||||||
|
entities map[string]*lang.Entity
|
||||||
|
pages map[string]*lang.Page
|
||||||
|
server *lang.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTMLInterpreter creates a new HTML interpreter
|
||||||
|
func NewHTMLInterpreter() *HTMLInterpreter {
|
||||||
|
return &HTMLInterpreter{
|
||||||
|
entities: make(map[string]*lang.Entity),
|
||||||
|
pages: make(map[string]*lang.Page),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanString removes surrounding quotes from string literals
|
||||||
|
func (hi *HTMLInterpreter) cleanString(s string) string {
|
||||||
|
if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) {
|
||||||
|
return s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeHTML escapes HTML special characters
|
||||||
|
func (hi *HTMLInterpreter) escapeHTML(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, "\"", """)
|
||||||
|
s = strings.ReplaceAll(s, "'", "'")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateHTML converts a Masonry AST to HTML output
|
||||||
|
func (hi *HTMLInterpreter) GenerateHTML(ast *lang.AST) (map[string]string, error) {
|
||||||
|
// First pass: collect entities, pages, and server config
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Entity != nil {
|
||||||
|
hi.entities[def.Entity.Name] = def.Entity
|
||||||
|
}
|
||||||
|
if def.Page != nil {
|
||||||
|
hi.pages[def.Page.Name] = def.Page
|
||||||
|
}
|
||||||
|
if def.Server != nil {
|
||||||
|
hi.server = def.Server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: generate HTML for each page
|
||||||
|
htmlFiles := make(map[string]string)
|
||||||
|
|
||||||
|
for pageName, page := range hi.pages {
|
||||||
|
html, err := hi.generatePageHTML(page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error generating HTML for page %s: %w", pageName, err)
|
||||||
|
}
|
||||||
|
htmlFiles[fmt.Sprintf("%s.html", strings.ToLower(pageName))] = html
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePageHTML creates HTML for a single page
|
||||||
|
func (hi *HTMLInterpreter) generatePageHTML(page *lang.Page) (string, error) {
|
||||||
|
var html strings.Builder
|
||||||
|
|
||||||
|
// HTML document structure
|
||||||
|
html.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
|
||||||
|
html.WriteString(" <meta charset=\"UTF-8\">\n")
|
||||||
|
html.WriteString(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
|
||||||
|
|
||||||
|
// Page title
|
||||||
|
title := page.Name
|
||||||
|
if page.Title != nil {
|
||||||
|
title = hi.cleanString(*page.Title)
|
||||||
|
}
|
||||||
|
html.WriteString(fmt.Sprintf(" <title>%s</title>\n", hi.escapeHTML(title)))
|
||||||
|
|
||||||
|
// Meta tags
|
||||||
|
for _, meta := range page.Meta {
|
||||||
|
cleanName := hi.cleanString(meta.Name)
|
||||||
|
cleanContent := hi.cleanString(meta.Content)
|
||||||
|
html.WriteString(fmt.Sprintf(" <meta name=\"%s\" content=\"%s\">\n",
|
||||||
|
hi.escapeHTML(cleanName), hi.escapeHTML(cleanContent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic CSS for styling
|
||||||
|
html.WriteString(" <style>\n")
|
||||||
|
html.WriteString(" body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }\n")
|
||||||
|
html.WriteString(" .container { max-width: 1200px; margin: 0 auto; }\n")
|
||||||
|
html.WriteString(" .section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px; }\n")
|
||||||
|
html.WriteString(" .tabs { border-bottom: 2px solid #ddd; margin-bottom: 20px; }\n")
|
||||||
|
html.WriteString(" .tab-button { padding: 10px 20px; border: none; background: none; cursor: pointer; }\n")
|
||||||
|
html.WriteString(" .tab-button.active { background: #007bff; color: white; }\n")
|
||||||
|
html.WriteString(" .tab-content { display: none; }\n")
|
||||||
|
html.WriteString(" .tab-content.active { display: block; }\n")
|
||||||
|
html.WriteString(" .form-group { margin: 15px 0; }\n")
|
||||||
|
html.WriteString(" .form-group label { display: block; margin-bottom: 5px; font-weight: bold; }\n")
|
||||||
|
html.WriteString(" .form-group input, .form-group textarea, .form-group select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }\n")
|
||||||
|
html.WriteString(" .button { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin: 5px; }\n")
|
||||||
|
html.WriteString(" .button-primary { background: #007bff; color: white; }\n")
|
||||||
|
html.WriteString(" .button-secondary { background: #6c757d; color: white; }\n")
|
||||||
|
html.WriteString(" .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); }\n")
|
||||||
|
html.WriteString(" .modal-content { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; min-width: 400px; }\n")
|
||||||
|
html.WriteString(" </style>\n")
|
||||||
|
html.WriteString("</head>\n<body>\n")
|
||||||
|
|
||||||
|
// Page content
|
||||||
|
html.WriteString(" <div class=\"container\">\n")
|
||||||
|
html.WriteString(fmt.Sprintf(" <h1>%s</h1>\n", hi.escapeHTML(title)))
|
||||||
|
|
||||||
|
// Generate sections
|
||||||
|
for _, section := range page.Sections {
|
||||||
|
sectionHTML, err := hi.generateSectionHTML(§ion, 2)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(sectionHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate direct components
|
||||||
|
for _, component := range page.Components {
|
||||||
|
componentHTML, err := hi.generateComponentHTML(&component, 2)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(componentHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(" </div>\n")
|
||||||
|
|
||||||
|
// JavaScript for interactivity
|
||||||
|
html.WriteString(" <script>\n")
|
||||||
|
|
||||||
|
// API Base URL configuration
|
||||||
|
apiBaseURL := "http://localhost:8080"
|
||||||
|
if hi.server != nil {
|
||||||
|
host := "localhost"
|
||||||
|
port := 8080
|
||||||
|
for _, setting := range hi.server.Settings {
|
||||||
|
if setting.Host != nil {
|
||||||
|
host = *setting.Host
|
||||||
|
}
|
||||||
|
if setting.Port != nil {
|
||||||
|
port = *setting.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiBaseURL = fmt.Sprintf("http://%s:%d", host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf(" const API_BASE_URL = '%s';\n", apiBaseURL))
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // API helper functions\n")
|
||||||
|
html.WriteString(" async function apiRequest(method, endpoint, data = null) {\n")
|
||||||
|
html.WriteString(" const config = {\n")
|
||||||
|
html.WriteString(" method: method,\n")
|
||||||
|
html.WriteString(" headers: {\n")
|
||||||
|
html.WriteString(" 'Content-Type': 'application/json',\n")
|
||||||
|
html.WriteString(" },\n")
|
||||||
|
html.WriteString(" };\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" if (data) {\n")
|
||||||
|
html.WriteString(" config.body = JSON.stringify(data);\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" try {\n")
|
||||||
|
html.WriteString(" const response = await fetch(API_BASE_URL + endpoint, config);\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" if (!response.ok) {\n")
|
||||||
|
html.WriteString(" throw new Error(`HTTP error! status: ${response.status}`);\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" if (response.status === 204) {\n")
|
||||||
|
html.WriteString(" return null; // No content\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" return await response.json();\n")
|
||||||
|
html.WriteString(" } catch (error) {\n")
|
||||||
|
html.WriteString(" console.error('API request failed:', error);\n")
|
||||||
|
html.WriteString(" alert('Error: ' + error.message);\n")
|
||||||
|
html.WriteString(" throw error;\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // Entity-specific API functions\n")
|
||||||
|
|
||||||
|
// Generate API functions for each entity
|
||||||
|
for entityName := range hi.entities {
|
||||||
|
entityLower := strings.ToLower(entityName)
|
||||||
|
entityPlural := entityLower + "s"
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf(" async function list%s() {\n", entityName))
|
||||||
|
html.WriteString(fmt.Sprintf(" return await apiRequest('GET', '/%s');\n", entityPlural))
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(fmt.Sprintf(" async function get%s(id) {\n", entityName))
|
||||||
|
html.WriteString(fmt.Sprintf(" return await apiRequest('GET', '/%s/' + id);\n", entityPlural))
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(fmt.Sprintf(" async function create%s(data) {\n", entityName))
|
||||||
|
html.WriteString(fmt.Sprintf(" return await apiRequest('POST', '/%s', data);\n", entityPlural))
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(fmt.Sprintf(" async function update%s(id, data) {\n", entityName))
|
||||||
|
html.WriteString(fmt.Sprintf(" return await apiRequest('PUT', '/%s/' + id, data);\n", entityPlural))
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(fmt.Sprintf(" async function delete%s(id) {\n", entityName))
|
||||||
|
html.WriteString(fmt.Sprintf(" return await apiRequest('DELETE', '/%s/' + id);\n", entityPlural))
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(" // Tab functionality\n")
|
||||||
|
html.WriteString(" function showTab(tabName) {\n")
|
||||||
|
html.WriteString(" const tabs = document.querySelectorAll('.tab-content');\n")
|
||||||
|
html.WriteString(" tabs.forEach(tab => tab.classList.remove('active'));\n")
|
||||||
|
html.WriteString(" const buttons = document.querySelectorAll('.tab-button');\n")
|
||||||
|
html.WriteString(" buttons.forEach(btn => btn.classList.remove('active'));\n")
|
||||||
|
html.WriteString(" document.getElementById(tabName).classList.add('active');\n")
|
||||||
|
html.WriteString(" event.target.classList.add('active');\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // Modal functionality\n")
|
||||||
|
html.WriteString(" function showModal(modalId) {\n")
|
||||||
|
html.WriteString(" document.getElementById(modalId).style.display = 'block';\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" function hideModal(modalId) {\n")
|
||||||
|
html.WriteString(" document.getElementById(modalId).style.display = 'none';\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // Enhanced form submission with API integration\n")
|
||||||
|
html.WriteString(" async function submitForm(formId, entityType = null) {\n")
|
||||||
|
html.WriteString(" const form = document.getElementById(formId);\n")
|
||||||
|
html.WriteString(" const formData = new FormData(form);\n")
|
||||||
|
html.WriteString(" const data = Object.fromEntries(formData);\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" console.log('Form data:', data);\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" if (entityType) {\n")
|
||||||
|
html.WriteString(" try {\n")
|
||||||
|
html.WriteString(" const result = await window['create' + entityType](data);\n")
|
||||||
|
html.WriteString(" console.log('Created:', result);\n")
|
||||||
|
html.WriteString(" alert(entityType + ' created successfully!');\n")
|
||||||
|
html.WriteString(" form.reset();\n")
|
||||||
|
html.WriteString(" // Refresh any tables showing this entity type\n")
|
||||||
|
html.WriteString(" await refreshTables(entityType);\n")
|
||||||
|
html.WriteString(" } catch (error) {\n")
|
||||||
|
html.WriteString(" console.error('Form submission failed:', error);\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" } else {\n")
|
||||||
|
html.WriteString(" alert('Form submitted successfully!');\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // Load data into tables\n")
|
||||||
|
html.WriteString(" async function loadTableData(tableId, entityType) {\n")
|
||||||
|
html.WriteString(" try {\n")
|
||||||
|
html.WriteString(" const data = await window['list' + entityType]();\n")
|
||||||
|
html.WriteString(" const table = document.getElementById(tableId);\n")
|
||||||
|
html.WriteString(" const tbody = table.querySelector('tbody');\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" if (!data || data.length === 0) {\n")
|
||||||
|
html.WriteString(" tbody.innerHTML = '<tr><td colspan=\"100%\">No data found</td></tr>';\n")
|
||||||
|
html.WriteString(" return;\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" tbody.innerHTML = '';\n")
|
||||||
|
html.WriteString(" data.forEach(item => {\n")
|
||||||
|
html.WriteString(" const row = document.createElement('tr');\n")
|
||||||
|
html.WriteString(" const headers = table.querySelectorAll('thead th');\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" headers.forEach((header, index) => {\n")
|
||||||
|
html.WriteString(" const cell = document.createElement('td');\n")
|
||||||
|
html.WriteString(" const fieldName = header.textContent.toLowerCase().replace(/\\s+/g, '_');\n")
|
||||||
|
html.WriteString(" cell.textContent = item[fieldName] || item[header.textContent.toLowerCase()] || '';\n")
|
||||||
|
html.WriteString(" row.appendChild(cell);\n")
|
||||||
|
html.WriteString(" });\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // Add action buttons\n")
|
||||||
|
html.WriteString(" const actionCell = document.createElement('td');\n")
|
||||||
|
html.WriteString(" actionCell.innerHTML = `\n")
|
||||||
|
html.WriteString(" <button onclick=\"editItem('${item.id}', '${entityType}')\" class=\"button button-primary\">Edit</button>\n")
|
||||||
|
html.WriteString(" <button onclick=\"deleteItem('${item.id}', '${entityType}')\" class=\"button button-secondary\">Delete</button>\n")
|
||||||
|
html.WriteString(" `;\n")
|
||||||
|
html.WriteString(" row.appendChild(actionCell);\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" tbody.appendChild(row);\n")
|
||||||
|
html.WriteString(" });\n")
|
||||||
|
html.WriteString(" } catch (error) {\n")
|
||||||
|
html.WriteString(" console.error('Failed to load table data:', error);\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // Refresh all tables of a specific entity type\n")
|
||||||
|
html.WriteString(" async function refreshTables(entityType) {\n")
|
||||||
|
html.WriteString(" const tables = document.querySelectorAll(`table[data-entity=\"${entityType}\"]`);\n")
|
||||||
|
html.WriteString(" tables.forEach(table => {\n")
|
||||||
|
html.WriteString(" loadTableData(table.id, entityType);\n")
|
||||||
|
html.WriteString(" });\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // Edit and delete functions\n")
|
||||||
|
html.WriteString(" async function editItem(id, entityType) {\n")
|
||||||
|
html.WriteString(" try {\n")
|
||||||
|
html.WriteString(" const item = await window['get' + entityType](id);\n")
|
||||||
|
html.WriteString(" console.log('Edit item:', item);\n")
|
||||||
|
html.WriteString(" // TODO: Populate form with item data for editing\n")
|
||||||
|
html.WriteString(" alert('Edit functionality coming soon!');\n")
|
||||||
|
html.WriteString(" } catch (error) {\n")
|
||||||
|
html.WriteString(" console.error('Failed to fetch item for editing:', error);\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" async function deleteItem(id, entityType) {\n")
|
||||||
|
html.WriteString(" if (confirm('Are you sure you want to delete this item?')) {\n")
|
||||||
|
html.WriteString(" try {\n")
|
||||||
|
html.WriteString(" await window['delete' + entityType](id);\n")
|
||||||
|
html.WriteString(" alert('Item deleted successfully!');\n")
|
||||||
|
html.WriteString(" await refreshTables(entityType);\n")
|
||||||
|
html.WriteString(" } catch (error) {\n")
|
||||||
|
html.WriteString(" console.error('Failed to delete item:', error);\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" }\n")
|
||||||
|
html.WriteString(" \n")
|
||||||
|
html.WriteString(" // Initialize page - load data when page loads\n")
|
||||||
|
html.WriteString(" document.addEventListener('DOMContentLoaded', function() {\n")
|
||||||
|
html.WriteString(" // Auto-load data for all tables with data-entity attribute\n")
|
||||||
|
html.WriteString(" const tables = document.querySelectorAll('table[data-entity]');\n")
|
||||||
|
html.WriteString(" tables.forEach(table => {\n")
|
||||||
|
html.WriteString(" const entityType = table.getAttribute('data-entity');\n")
|
||||||
|
html.WriteString(" loadTableData(table.id, entityType);\n")
|
||||||
|
html.WriteString(" });\n")
|
||||||
|
html.WriteString(" });\n")
|
||||||
|
html.WriteString(" </script>\n")
|
||||||
|
|
||||||
|
html.WriteString("</body>\n</html>")
|
||||||
|
|
||||||
|
return html.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSectionHTML creates HTML for a section
|
||||||
|
func (hi *HTMLInterpreter) generateSectionHTML(section *lang.Section, indent int) (string, error) {
|
||||||
|
var html strings.Builder
|
||||||
|
indentStr := strings.Repeat(" ", indent)
|
||||||
|
|
||||||
|
sectionType := "container"
|
||||||
|
if section.Type != nil {
|
||||||
|
sectionType = *section.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sectionType {
|
||||||
|
case "tab":
|
||||||
|
html.WriteString(fmt.Sprintf("%s<div class=\"tabs\" id=\"%s\">\n", indentStr, section.Name))
|
||||||
|
|
||||||
|
// Generate tab buttons
|
||||||
|
for _, element := range section.Elements {
|
||||||
|
if element.Section != nil && element.Section.Label != nil {
|
||||||
|
activeClass := ""
|
||||||
|
if element.Section.Active {
|
||||||
|
activeClass = " active"
|
||||||
|
}
|
||||||
|
cleanLabel := hi.cleanString(*element.Section.Label)
|
||||||
|
html.WriteString(fmt.Sprintf("%s <button class=\"tab-button%s\" onclick=\"showTab('%s')\">%s</button>\n",
|
||||||
|
indentStr, activeClass, element.Section.Name, hi.escapeHTML(cleanLabel)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tab content
|
||||||
|
for _, element := range section.Elements {
|
||||||
|
if element.Section != nil {
|
||||||
|
activeClass := ""
|
||||||
|
if element.Section.Active {
|
||||||
|
activeClass = " active"
|
||||||
|
}
|
||||||
|
html.WriteString(fmt.Sprintf("%s <div class=\"tab-content%s\" id=\"%s\">\n",
|
||||||
|
indentStr, activeClass, element.Section.Name))
|
||||||
|
|
||||||
|
sectionHTML, err := hi.generateSectionHTML(element.Section, indent+2)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(sectionHTML)
|
||||||
|
html.WriteString(fmt.Sprintf("%s </div>\n", indentStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s</div>\n", indentStr))
|
||||||
|
|
||||||
|
case "modal":
|
||||||
|
html.WriteString(fmt.Sprintf("%s<div class=\"modal\" id=\"%s\">\n", indentStr, section.Name))
|
||||||
|
html.WriteString(fmt.Sprintf("%s <div class=\"modal-content\">\n", indentStr))
|
||||||
|
if section.Label != nil {
|
||||||
|
cleanLabel := hi.cleanString(*section.Label)
|
||||||
|
html.WriteString(fmt.Sprintf("%s <h3>%s</h3>\n", indentStr, hi.escapeHTML(cleanLabel)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate modal content
|
||||||
|
for _, element := range section.Elements {
|
||||||
|
elementHTML, err := hi.generateSectionElementHTML(&element, indent+2)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(elementHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s <button class=\"button button-secondary\" onclick=\"hideModal('%s')\">Close</button>\n", indentStr, section.Name))
|
||||||
|
html.WriteString(fmt.Sprintf("%s </div>\n", indentStr))
|
||||||
|
html.WriteString(fmt.Sprintf("%s</div>\n", indentStr))
|
||||||
|
|
||||||
|
default: // container, panel, master, detail
|
||||||
|
cssClass := "section"
|
||||||
|
if section.Class != nil {
|
||||||
|
cssClass = hi.cleanString(*section.Class)
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s<div class=\"%s\" id=\"%s\">\n", indentStr, hi.escapeHTML(cssClass), section.Name))
|
||||||
|
|
||||||
|
if section.Label != nil {
|
||||||
|
cleanLabel := hi.cleanString(*section.Label)
|
||||||
|
html.WriteString(fmt.Sprintf("%s <h3>%s</h3>\n", indentStr, hi.escapeHTML(cleanLabel)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate section content
|
||||||
|
for _, element := range section.Elements {
|
||||||
|
elementHTML, err := hi.generateSectionElementHTML(&element, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(elementHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s</div>\n", indentStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSectionElementHTML creates HTML for section elements
|
||||||
|
func (hi *HTMLInterpreter) generateSectionElementHTML(element *lang.SectionElement, indent int) (string, error) {
|
||||||
|
if element.Component != nil {
|
||||||
|
return hi.generateComponentHTML(element.Component, indent)
|
||||||
|
}
|
||||||
|
if element.Section != nil {
|
||||||
|
return hi.generateSectionHTML(element.Section, indent)
|
||||||
|
}
|
||||||
|
if element.When != nil {
|
||||||
|
return hi.generateWhenConditionHTML(element.When, indent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateComponentHTML creates HTML for a component
|
||||||
|
func (hi *HTMLInterpreter) generateComponentHTML(component *lang.Component, indent int) (string, error) {
|
||||||
|
var html strings.Builder
|
||||||
|
indentStr := strings.Repeat(" ", indent)
|
||||||
|
|
||||||
|
switch component.Type {
|
||||||
|
case "form":
|
||||||
|
formId := fmt.Sprintf("form_%s", component.Type)
|
||||||
|
if component.Entity != nil {
|
||||||
|
formId = fmt.Sprintf("form_%s", *component.Entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s<form id=\"%s\" onsubmit=\"event.preventDefault(); submitForm('%s');\">\n", indentStr, formId, formId))
|
||||||
|
|
||||||
|
// Generate form fields
|
||||||
|
for _, element := range component.Elements {
|
||||||
|
if element.Field != nil {
|
||||||
|
fieldHTML, err := hi.generateFieldHTML(element.Field, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(fieldHTML)
|
||||||
|
}
|
||||||
|
if element.Button != nil {
|
||||||
|
buttonHTML, err := hi.generateButtonHTML(element.Button, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(buttonHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s <button type=\"submit\" class=\"button button-primary\">Submit</button>\n", indentStr))
|
||||||
|
html.WriteString(fmt.Sprintf("%s</form>\n", indentStr))
|
||||||
|
|
||||||
|
case "table":
|
||||||
|
html.WriteString(fmt.Sprintf("%s<table class=\"table\">\n", indentStr))
|
||||||
|
html.WriteString(fmt.Sprintf("%s <thead><tr>\n", indentStr))
|
||||||
|
|
||||||
|
// Generate table headers from fields
|
||||||
|
for _, element := range component.Elements {
|
||||||
|
if element.Field != nil {
|
||||||
|
label := element.Field.Name
|
||||||
|
for _, attr := range element.Field.Attributes {
|
||||||
|
if attr.Label != nil {
|
||||||
|
label = hi.cleanString(*attr.Label)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.WriteString(fmt.Sprintf("%s <th>%s</th>\n", indentStr, hi.escapeHTML(label)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s </tr></thead>\n", indentStr))
|
||||||
|
html.WriteString(fmt.Sprintf("%s <tbody>\n", indentStr))
|
||||||
|
html.WriteString(fmt.Sprintf("%s <tr><td colspan=\"100%%\">Data will be loaded here...</td></tr>\n", indentStr))
|
||||||
|
html.WriteString(fmt.Sprintf("%s </tbody>\n", indentStr))
|
||||||
|
html.WriteString(fmt.Sprintf("%s</table>\n", indentStr))
|
||||||
|
|
||||||
|
default:
|
||||||
|
html.WriteString(fmt.Sprintf("%s<div class=\"component-%s\">\n", indentStr, component.Type))
|
||||||
|
|
||||||
|
// Generate component content
|
||||||
|
for _, element := range component.Elements {
|
||||||
|
if element.Field != nil {
|
||||||
|
fieldHTML, err := hi.generateFieldHTML(element.Field, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(fieldHTML)
|
||||||
|
}
|
||||||
|
if element.Button != nil {
|
||||||
|
buttonHTML, err := hi.generateButtonHTML(element.Button, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(buttonHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s</div>\n", indentStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateFieldHTML creates HTML for a field
|
||||||
|
func (hi *HTMLInterpreter) generateFieldHTML(field *lang.ComponentField, indent int) (string, error) {
|
||||||
|
var html strings.Builder
|
||||||
|
indentStr := strings.Repeat(" ", indent)
|
||||||
|
|
||||||
|
// Get field attributes
|
||||||
|
var label, placeholder, defaultValue string
|
||||||
|
var required bool
|
||||||
|
var options []string
|
||||||
|
|
||||||
|
label = field.Name
|
||||||
|
for _, attr := range field.Attributes {
|
||||||
|
if attr.Label != nil {
|
||||||
|
label = hi.cleanString(*attr.Label)
|
||||||
|
}
|
||||||
|
if attr.Placeholder != nil {
|
||||||
|
placeholder = hi.cleanString(*attr.Placeholder)
|
||||||
|
}
|
||||||
|
if attr.Default != nil {
|
||||||
|
defaultValue = hi.cleanString(*attr.Default)
|
||||||
|
}
|
||||||
|
if attr.Required {
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
if attr.Options != nil {
|
||||||
|
// Clean each option in the array
|
||||||
|
options = make([]string, len(attr.Options))
|
||||||
|
for i, opt := range attr.Options {
|
||||||
|
options[i] = hi.cleanString(opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s<div class=\"form-group\">\n", indentStr))
|
||||||
|
|
||||||
|
requiredAttr := ""
|
||||||
|
if required {
|
||||||
|
requiredAttr = " required"
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s <label for=\"%s\">%s</label>\n", indentStr, field.Name, hi.escapeHTML(label)))
|
||||||
|
|
||||||
|
switch field.Type {
|
||||||
|
case "text", "email", "password", "number", "tel", "url":
|
||||||
|
html.WriteString(fmt.Sprintf("%s <input type=\"%s\" id=\"%s\" name=\"%s\" placeholder=\"%s\" value=\"%s\"%s>\n",
|
||||||
|
indentStr, field.Type, field.Name, field.Name, hi.escapeHTML(placeholder), hi.escapeHTML(defaultValue), requiredAttr))
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
html.WriteString(fmt.Sprintf("%s <textarea id=\"%s\" name=\"%s\" placeholder=\"%s\"%s>%s</textarea>\n",
|
||||||
|
indentStr, field.Name, field.Name, hi.escapeHTML(placeholder), requiredAttr, hi.escapeHTML(defaultValue)))
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
html.WriteString(fmt.Sprintf("%s <select id=\"%s\" name=\"%s\"%s>\n", indentStr, field.Name, field.Name, requiredAttr))
|
||||||
|
if !required {
|
||||||
|
html.WriteString(fmt.Sprintf("%s <option value=\"\">Select an option</option>\n", indentStr))
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
selected := ""
|
||||||
|
if option == defaultValue {
|
||||||
|
selected = " selected"
|
||||||
|
}
|
||||||
|
html.WriteString(fmt.Sprintf("%s <option value=\"%s\"%s>%s</option>\n",
|
||||||
|
indentStr, hi.escapeHTML(option), selected, hi.escapeHTML(option)))
|
||||||
|
}
|
||||||
|
html.WriteString(fmt.Sprintf("%s </select>\n", indentStr))
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
checked := ""
|
||||||
|
if defaultValue == "true" {
|
||||||
|
checked = " checked"
|
||||||
|
}
|
||||||
|
html.WriteString(fmt.Sprintf("%s <input type=\"checkbox\" id=\"%s\" name=\"%s\" value=\"true\"%s%s>\n",
|
||||||
|
indentStr, field.Name, field.Name, checked, requiredAttr))
|
||||||
|
|
||||||
|
case "file":
|
||||||
|
html.WriteString(fmt.Sprintf("%s <input type=\"file\" id=\"%s\" name=\"%s\"%s>\n",
|
||||||
|
indentStr, field.Name, field.Name, requiredAttr))
|
||||||
|
|
||||||
|
default:
|
||||||
|
html.WriteString(fmt.Sprintf("%s <input type=\"text\" id=\"%s\" name=\"%s\" placeholder=\"%s\" value=\"%s\"%s>\n",
|
||||||
|
indentStr, field.Type, field.Name, field.Name, hi.escapeHTML(placeholder), hi.escapeHTML(defaultValue), requiredAttr))
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s</div>\n", indentStr))
|
||||||
|
|
||||||
|
return html.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateButtonHTML creates HTML for a button
|
||||||
|
func (hi *HTMLInterpreter) generateButtonHTML(button *lang.ComponentButton, indent int) (string, error) {
|
||||||
|
var html strings.Builder
|
||||||
|
indentStr := strings.Repeat(" ", indent)
|
||||||
|
|
||||||
|
buttonClass := "button button-primary"
|
||||||
|
onclick := ""
|
||||||
|
|
||||||
|
// Process button attributes
|
||||||
|
for _, attr := range button.Attributes {
|
||||||
|
if attr.Style != nil {
|
||||||
|
// Handle button style
|
||||||
|
}
|
||||||
|
if attr.Target != nil {
|
||||||
|
// Handle button target/onclick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanLabel := hi.cleanString(button.Label)
|
||||||
|
html.WriteString(fmt.Sprintf("%s<button type=\"button\" class=\"%s\" onclick=\"%s\">%s</button>\n",
|
||||||
|
indentStr, buttonClass, onclick, hi.escapeHTML(cleanLabel)))
|
||||||
|
|
||||||
|
return html.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateWhenConditionHTML creates HTML for conditional content
|
||||||
|
func (hi *HTMLInterpreter) generateWhenConditionHTML(when *lang.WhenCondition, indent int) (string, error) {
|
||||||
|
var html strings.Builder
|
||||||
|
indentStr := strings.Repeat(" ", indent)
|
||||||
|
|
||||||
|
// For now, we'll render the content as visible (real implementation would include JavaScript logic)
|
||||||
|
html.WriteString(fmt.Sprintf("%s<div class=\"conditional-content\" data-when-field=\"%s\" data-when-operator=\"%s\" data-when-value=\"%s\">\n",
|
||||||
|
indentStr, when.Field, when.Operator, when.Value))
|
||||||
|
|
||||||
|
// Generate conditional content
|
||||||
|
for _, field := range when.Fields {
|
||||||
|
fieldHTML, err := hi.generateFieldHTML(&field, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(fieldHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, section := range when.Sections {
|
||||||
|
sectionHTML, err := hi.generateSectionHTML(§ion, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(sectionHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, component := range when.Components {
|
||||||
|
componentHTML, err := hi.generateComponentHTML(&component, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(componentHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, button := range when.Buttons {
|
||||||
|
buttonHTML, err := hi.generateButtonHTML(&button, indent+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
html.WriteString(buttonHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
html.WriteString(fmt.Sprintf("%s</div>\n", indentStr))
|
||||||
|
|
||||||
|
return html.String(), nil
|
||||||
|
}
|
386
interpreter/server_interpreter.go
Normal file
386
interpreter/server_interpreter.go
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
package interpreter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"masonry/lang"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerInterpreter converts Masonry AST to a simple Go HTTP server
|
||||||
|
type ServerInterpreter struct {
|
||||||
|
entities map[string]*lang.Entity
|
||||||
|
endpoints map[string]*lang.Endpoint
|
||||||
|
server *lang.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerInterpreter creates a new server interpreter
|
||||||
|
func NewServerInterpreter() *ServerInterpreter {
|
||||||
|
return &ServerInterpreter{
|
||||||
|
entities: make(map[string]*lang.Entity),
|
||||||
|
endpoints: make(map[string]*lang.Endpoint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpret processes the AST and generates server code
|
||||||
|
func (si *ServerInterpreter) Interpret(ast lang.AST) (string, error) {
|
||||||
|
// First pass: collect all definitions
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Server != nil {
|
||||||
|
si.server = def.Server
|
||||||
|
}
|
||||||
|
if def.Entity != nil {
|
||||||
|
si.entities[def.Entity.Name] = def.Entity
|
||||||
|
}
|
||||||
|
if def.Endpoint != nil {
|
||||||
|
key := fmt.Sprintf("%s_%s", def.Endpoint.Method, strings.ReplaceAll(def.Endpoint.Path, "/", "_"))
|
||||||
|
si.endpoints[key] = def.Endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the server code
|
||||||
|
rawCode, err := si.generateServer()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the code to remove unused imports and fix formatting
|
||||||
|
formattedCode, err := format.Source([]byte(rawCode))
|
||||||
|
if err != nil {
|
||||||
|
// If formatting fails, return the raw code with a warning comment
|
||||||
|
return "// Warning: Code formatting failed, but code should still be functional\n" + rawCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(formattedCode), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateServer creates the complete server code
|
||||||
|
func (si *ServerInterpreter) generateServer() (string, error) {
|
||||||
|
var code strings.Builder
|
||||||
|
|
||||||
|
// Package and imports - only include what's actually used
|
||||||
|
code.WriteString(`package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Generate entity structs
|
||||||
|
code.WriteString("// Entity definitions\n")
|
||||||
|
for _, entity := range si.entities {
|
||||||
|
code.WriteString(si.generateEntityStruct(entity))
|
||||||
|
code.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate in-memory database
|
||||||
|
code.WriteString(si.generateInMemoryDB())
|
||||||
|
|
||||||
|
// Generate handlers
|
||||||
|
code.WriteString("// HTTP Handlers\n")
|
||||||
|
for _, entity := range si.entities {
|
||||||
|
code.WriteString(si.generateEntityHandlers(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate custom endpoint handlers
|
||||||
|
for _, endpoint := range si.endpoints {
|
||||||
|
if endpoint.Entity == nil {
|
||||||
|
code.WriteString(si.generateCustomEndpointHandler(endpoint))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate main function
|
||||||
|
code.WriteString(si.generateMainFunction())
|
||||||
|
|
||||||
|
return code.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateEntityStruct creates a Go struct for an entity
|
||||||
|
func (si *ServerInterpreter) generateEntityStruct(entity *lang.Entity) string {
|
||||||
|
var code strings.Builder
|
||||||
|
|
||||||
|
if entity.Description != nil {
|
||||||
|
code.WriteString(fmt.Sprintf("// %s - %s\n", entity.Name, *entity.Description))
|
||||||
|
}
|
||||||
|
|
||||||
|
code.WriteString(fmt.Sprintf("type %s struct {\n", entity.Name))
|
||||||
|
|
||||||
|
// Always add ID field
|
||||||
|
code.WriteString("\tID string `json:\"id\"`\n")
|
||||||
|
code.WriteString("\tCreatedAt time.Time `json:\"created_at\"`\n")
|
||||||
|
code.WriteString("\tUpdatedAt time.Time `json:\"updated_at\"`\n")
|
||||||
|
|
||||||
|
for _, field := range entity.Fields {
|
||||||
|
goType := si.convertToGoType(field.Type)
|
||||||
|
jsonTag := strings.ToLower(field.Name)
|
||||||
|
|
||||||
|
if !field.Required {
|
||||||
|
goType = "*" + goType
|
||||||
|
}
|
||||||
|
|
||||||
|
code.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n",
|
||||||
|
cases.Title(language.English).String(field.Name), goType, jsonTag))
|
||||||
|
}
|
||||||
|
|
||||||
|
code.WriteString("}\n")
|
||||||
|
return code.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToGoType maps Masonry types to Go types
|
||||||
|
func (si *ServerInterpreter) convertToGoType(masonryType string) string {
|
||||||
|
switch masonryType {
|
||||||
|
case "string", "text", "email", "url":
|
||||||
|
return "string"
|
||||||
|
case "int", "number":
|
||||||
|
return "int"
|
||||||
|
case "float":
|
||||||
|
return "float64"
|
||||||
|
case "boolean":
|
||||||
|
return "bool"
|
||||||
|
case "uuid":
|
||||||
|
return "string"
|
||||||
|
case "timestamp":
|
||||||
|
return "time.Time"
|
||||||
|
default:
|
||||||
|
return "string" // default fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateInMemoryDB creates the in-memory database structure
|
||||||
|
func (si *ServerInterpreter) generateInMemoryDB() string {
|
||||||
|
var code strings.Builder
|
||||||
|
|
||||||
|
code.WriteString("// In-memory database\n")
|
||||||
|
code.WriteString("type InMemoryDB struct {\n")
|
||||||
|
code.WriteString("\tmu sync.RWMutex\n")
|
||||||
|
|
||||||
|
for entityName := range si.entities {
|
||||||
|
code.WriteString(fmt.Sprintf("\t%s map[string]*%s\n",
|
||||||
|
strings.ToLower(entityName)+"s", entityName))
|
||||||
|
}
|
||||||
|
|
||||||
|
code.WriteString("}\n\n")
|
||||||
|
|
||||||
|
code.WriteString("var db = &InMemoryDB{\n")
|
||||||
|
for entityName := range si.entities {
|
||||||
|
code.WriteString(fmt.Sprintf("\t%s: make(map[string]*%s),\n",
|
||||||
|
strings.ToLower(entityName)+"s", entityName))
|
||||||
|
}
|
||||||
|
code.WriteString("}\n\n")
|
||||||
|
|
||||||
|
return code.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateEntityHandlers creates CRUD handlers for an entity
|
||||||
|
func (si *ServerInterpreter) generateEntityHandlers(entity *lang.Entity) string {
|
||||||
|
var code strings.Builder
|
||||||
|
entityName := entity.Name
|
||||||
|
entityLower := strings.ToLower(entityName)
|
||||||
|
entityPlural := entityLower + "s"
|
||||||
|
|
||||||
|
// List handler
|
||||||
|
code.WriteString(fmt.Sprintf(`func list%sHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db.mu.RLock()
|
||||||
|
defer db.mu.RUnlock()
|
||||||
|
|
||||||
|
var items []*%s
|
||||||
|
for _, item := range db.%s {
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
`, entityName, entityName, entityPlural))
|
||||||
|
|
||||||
|
// Get by ID handler
|
||||||
|
code.WriteString(fmt.Sprintf(`func get%sHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
db.mu.RLock()
|
||||||
|
item, exists := db.%s[id]
|
||||||
|
db.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
http.Error(w, "%s not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
`, entityName, entityPlural, entityName))
|
||||||
|
|
||||||
|
// Create handler
|
||||||
|
code.WriteString(fmt.Sprintf(`func create%sHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var item %s
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item.ID = uuid.New().String()
|
||||||
|
item.CreatedAt = time.Now()
|
||||||
|
item.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
db.mu.Lock()
|
||||||
|
db.%s[item.ID] = &item
|
||||||
|
db.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
`, entityName, entityName, entityPlural))
|
||||||
|
|
||||||
|
// Update handler
|
||||||
|
code.WriteString(fmt.Sprintf(`func update%sHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
existing, exists := db.%s[id]
|
||||||
|
if !exists {
|
||||||
|
http.Error(w, "%s not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates %s
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve ID and timestamps
|
||||||
|
updates.ID = existing.ID
|
||||||
|
updates.CreatedAt = existing.CreatedAt
|
||||||
|
updates.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
db.%s[id] = &updates
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
`, entityName, entityPlural, entityName, entityName, entityPlural))
|
||||||
|
|
||||||
|
// Delete handler
|
||||||
|
code.WriteString(fmt.Sprintf(`func delete%sHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := db.%s[id]; !exists {
|
||||||
|
http.Error(w, "%s not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(db.%s, id)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
`, entityName, entityPlural, entityName, entityPlural))
|
||||||
|
|
||||||
|
return code.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCustomEndpointHandler creates handlers for custom endpoints
|
||||||
|
func (si *ServerInterpreter) generateCustomEndpointHandler(endpoint *lang.Endpoint) string {
|
||||||
|
var code strings.Builder
|
||||||
|
|
||||||
|
handlerName := fmt.Sprintf("%s%sHandler",
|
||||||
|
strings.ToLower(endpoint.Method),
|
||||||
|
strings.ReplaceAll(cases.Title(language.English).String(endpoint.Path), "/", ""))
|
||||||
|
|
||||||
|
code.WriteString(fmt.Sprintf(`func %s(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Custom endpoint: %s %s
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"message": "Custom endpoint %s %s",
|
||||||
|
"status": "success",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
`, handlerName, endpoint.Method, endpoint.Path, endpoint.Method, endpoint.Path))
|
||||||
|
|
||||||
|
return code.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateMainFunction creates the main function with routing
|
||||||
|
func (si *ServerInterpreter) generateMainFunction() string {
|
||||||
|
var code strings.Builder
|
||||||
|
|
||||||
|
code.WriteString("func main() {\n")
|
||||||
|
code.WriteString("\tr := mux.NewRouter()\n\n")
|
||||||
|
|
||||||
|
// Add routes for each entity
|
||||||
|
for entityName := range si.entities {
|
||||||
|
entityLower := strings.ToLower(entityName)
|
||||||
|
entityPlural := entityLower + "s"
|
||||||
|
|
||||||
|
code.WriteString(fmt.Sprintf("\t// %s routes\n", entityName))
|
||||||
|
code.WriteString(fmt.Sprintf("\tr.HandleFunc(\"/%s\", list%sHandler).Methods(\"GET\")\n",
|
||||||
|
entityPlural, entityName))
|
||||||
|
code.WriteString(fmt.Sprintf("\tr.HandleFunc(\"/%s/{id}\", get%sHandler).Methods(\"GET\")\n",
|
||||||
|
entityPlural, entityName))
|
||||||
|
code.WriteString(fmt.Sprintf("\tr.HandleFunc(\"/%s\", create%sHandler).Methods(\"POST\")\n",
|
||||||
|
entityPlural, entityName))
|
||||||
|
code.WriteString(fmt.Sprintf("\tr.HandleFunc(\"/%s/{id}\", update%sHandler).Methods(\"PUT\")\n",
|
||||||
|
entityPlural, entityName))
|
||||||
|
code.WriteString(fmt.Sprintf("\tr.HandleFunc(\"/%s/{id}\", delete%sHandler).Methods(\"DELETE\")\n",
|
||||||
|
entityPlural, entityName))
|
||||||
|
code.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom endpoint routes
|
||||||
|
for _, endpoint := range si.endpoints {
|
||||||
|
if endpoint.Entity == nil {
|
||||||
|
handlerName := fmt.Sprintf("%s%sHandler",
|
||||||
|
strings.ToLower(endpoint.Method),
|
||||||
|
strings.ReplaceAll(cases.Title(language.English).String(endpoint.Path), "/", ""))
|
||||||
|
code.WriteString(fmt.Sprintf("\tr.HandleFunc(\"%s\", %s).Methods(\"%s\")\n",
|
||||||
|
endpoint.Path, handlerName, endpoint.Method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server configuration
|
||||||
|
host := "localhost"
|
||||||
|
port := 8080
|
||||||
|
|
||||||
|
if si.server != nil {
|
||||||
|
for _, setting := range si.server.Settings {
|
||||||
|
if setting.Host != nil {
|
||||||
|
host = *setting.Host
|
||||||
|
}
|
||||||
|
if setting.Port != nil {
|
||||||
|
port = *setting.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code.WriteString(fmt.Sprintf("\n\taddr := \"%s:%d\"\n", host, port))
|
||||||
|
code.WriteString("\tfmt.Printf(\"Server starting on %s\\n\", addr)\n")
|
||||||
|
code.WriteString("\tlog.Fatal(http.ListenAndServe(addr, r))\n")
|
||||||
|
code.WriteString("}\n")
|
||||||
|
|
||||||
|
return code.String()
|
||||||
|
}
|
281
lang/lang.go
Normal file
281
lang/lang.go
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/alecthomas/participle/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Root AST node containing all definitions
|
||||||
|
type AST struct {
|
||||||
|
Definitions []Definition `parser:"@@*"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for top-level definitions
|
||||||
|
type Definition struct {
|
||||||
|
Server *Server `parser:"@@"`
|
||||||
|
Entity *Entity `parser:"| @@"`
|
||||||
|
Endpoint *Endpoint `parser:"| @@"`
|
||||||
|
Page *Page `parser:"| @@"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean server syntax
|
||||||
|
type Server struct {
|
||||||
|
Name string `parser:"'server' @Ident"`
|
||||||
|
Settings []ServerSetting `parser:"('{' @@* '}')?"` // Block-delimited settings for consistency
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerSetting struct {
|
||||||
|
Host *string `parser:"('host' @String)"`
|
||||||
|
Port *int `parser:"| ('port' @Int)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean entity syntax with better readability
|
||||||
|
type Entity struct {
|
||||||
|
Name string `parser:"'entity' @Ident"`
|
||||||
|
Description *string `parser:"('desc' @String)?"`
|
||||||
|
Fields []Field `parser:"('{' @@* '}')?"` // Block-delimited fields for consistency
|
||||||
|
}
|
||||||
|
|
||||||
|
// Much cleaner field syntax
|
||||||
|
type Field struct {
|
||||||
|
Name string `parser:"@Ident ':'"`
|
||||||
|
Type string `parser:"@Ident"`
|
||||||
|
Required bool `parser:"@'required'?"`
|
||||||
|
Unique bool `parser:"@'unique'?"`
|
||||||
|
Index bool `parser:"@'indexed'?"`
|
||||||
|
Default *string `parser:"('default' @String)?"`
|
||||||
|
Validations []Validation `parser:"@@*"`
|
||||||
|
Relationship *Relationship `parser:"@@?"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple validation syntax
|
||||||
|
type Validation struct {
|
||||||
|
Type string `parser:"'validate' @Ident"`
|
||||||
|
Value *string `parser:"@String?"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear relationship syntax
|
||||||
|
type Relationship struct {
|
||||||
|
Type string `parser:"'relates' 'to' @Ident"`
|
||||||
|
Cardinality string `parser:"'as' @('one' | 'many')"`
|
||||||
|
ForeignKey *string `parser:"('via' @String)?"`
|
||||||
|
Through *string `parser:"('through' @String)?"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint definitions with clean, readable syntax
|
||||||
|
type Endpoint struct {
|
||||||
|
Method string `parser:"'endpoint' @('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH')"`
|
||||||
|
Path string `parser:"@String"`
|
||||||
|
Entity *string `parser:"('for' @Ident)?"`
|
||||||
|
Description *string `parser:"('desc' @String)?"`
|
||||||
|
Auth bool `parser:"@'auth'?"`
|
||||||
|
Params []EndpointParam `parser:"('{' @@*"` // Block-delimited parameters
|
||||||
|
Response *ResponseSpec `parser:"@@?"`
|
||||||
|
CustomLogic *string `parser:"('custom' @String)? '}')?"` // Close block after all content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean parameter syntax
|
||||||
|
type EndpointParam struct {
|
||||||
|
Name string `parser:"'param' @Ident ':'"`
|
||||||
|
Type string `parser:"@Ident"`
|
||||||
|
Required bool `parser:"@'required'?"`
|
||||||
|
Source string `parser:"'from' @('path' | 'query' | 'body')"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response specification
|
||||||
|
type ResponseSpec struct {
|
||||||
|
Type string `parser:"'returns' @Ident"`
|
||||||
|
Format *string `parser:"('as' @String)?"`
|
||||||
|
Fields []string `parser:"('fields' '[' @Ident (',' @Ident)* ']')?"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced Page definitions with unified section model
|
||||||
|
type Page struct {
|
||||||
|
Name string `parser:"'page' @Ident"`
|
||||||
|
Path string `parser:"'at' @String"`
|
||||||
|
Layout string `parser:"'layout' @Ident"`
|
||||||
|
Title *string `parser:"('title' @String)?"`
|
||||||
|
Description *string `parser:"('desc' @String)?"`
|
||||||
|
Auth bool `parser:"@'auth'?"`
|
||||||
|
Meta []MetaTag `parser:"('{' @@*"` // Block-delimited content
|
||||||
|
Sections []Section `parser:"@@*"` // Unified sections replace containers/tabs/panels/modals
|
||||||
|
Components []Component `parser:"@@* '}')?"` // Direct components within the block
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta tags for SEO
|
||||||
|
type MetaTag struct {
|
||||||
|
Name string `parser:"'meta' @Ident"`
|
||||||
|
Content string `parser:"@String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified Section type that replaces Container, Tab, Panel, Modal, MasterDetail
|
||||||
|
type Section struct {
|
||||||
|
Name string `parser:"'section' @Ident"`
|
||||||
|
Type *string `parser:"('type' @('container' | 'tab' | 'panel' | 'modal' | 'master' | 'detail'))?"`
|
||||||
|
Class *string `parser:"('class' @String)?"`
|
||||||
|
Label *string `parser:"('label' @String)?"` // for tabs
|
||||||
|
Active bool `parser:"@'active'?"` // for tabs
|
||||||
|
Trigger *string `parser:"('trigger' @String)?"` // for panels/modals/detail
|
||||||
|
Position *string `parser:"('position' @String)?"` // for panels
|
||||||
|
Entity *string `parser:"('for' @Ident)?"` // for panels
|
||||||
|
Elements []SectionElement `parser:"('{' @@* '}')?"` // Block-delimited elements for unambiguous nesting
|
||||||
|
}
|
||||||
|
|
||||||
|
// New unified element type for sections
|
||||||
|
type SectionElement struct {
|
||||||
|
Attribute *SectionAttribute `parser:"@@"`
|
||||||
|
Component *Component `parser:"| @@"`
|
||||||
|
Section *Section `parser:"| @@"`
|
||||||
|
When *WhenCondition `parser:"| @@"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flexible section attributes (replaces complex config types)
|
||||||
|
type SectionAttribute struct {
|
||||||
|
DataSource *string `parser:"('data' 'from' @String)"`
|
||||||
|
Style *string `parser:"| ('style' @String)"`
|
||||||
|
Classes *string `parser:"| ('classes' @String)"`
|
||||||
|
Size *int `parser:"| ('size' @Int)"` // for pagination, etc.
|
||||||
|
Theme *string `parser:"| ('theme' @String)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified Component with unified attributes - reordered for better parsing
|
||||||
|
type Component struct {
|
||||||
|
Type string `parser:"'component' @Ident"`
|
||||||
|
Entity *string `parser:"('for' @Ident)?"`
|
||||||
|
Elements []ComponentElement `parser:"('{' @@* '}')?"` // Parse everything inside the block
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced ComponentElement with recursive section support - now includes attributes
|
||||||
|
type ComponentElement struct {
|
||||||
|
Attribute *ComponentAttr `parser:"@@"` // Component attributes can be inside the block
|
||||||
|
Field *ComponentField `parser:"| @@"`
|
||||||
|
Section *Section `parser:"| @@"` // Sections can be nested in components
|
||||||
|
Button *ComponentButton `parser:"| @@"`
|
||||||
|
When *WhenCondition `parser:"| @@"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified component attributes using key-value pattern - reordered for precedence
|
||||||
|
type ComponentAttr struct {
|
||||||
|
DataSource *string `parser:"('data' 'from' @String)"`
|
||||||
|
Fields []string `parser:"| ('fields' '[' @Ident (',' @Ident)* ']')"`
|
||||||
|
Actions []string `parser:"| ('actions' '[' @Ident (',' @Ident)* ']')"`
|
||||||
|
Style *string `parser:"| ('style' @String)"`
|
||||||
|
Classes *string `parser:"| ('classes' @String)"`
|
||||||
|
PageSize *int `parser:"| ('pagination' 'size' @Int)"`
|
||||||
|
Validate bool `parser:"| @'validate'"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced component field with detailed configuration using flexible attributes
|
||||||
|
type ComponentField struct {
|
||||||
|
Name string `parser:"'field' @Ident"`
|
||||||
|
Type string `parser:"'type' @Ident"`
|
||||||
|
Attributes []ComponentFieldAttribute `parser:"@@* ('{' @@* '}')?"` // Support both inline and block attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flexible field attribute system
|
||||||
|
type ComponentFieldAttribute struct {
|
||||||
|
Label *string `parser:"('label' @String)"`
|
||||||
|
Placeholder *string `parser:"| ('placeholder' @String)"`
|
||||||
|
Required bool `parser:"| @'required'"`
|
||||||
|
Sortable bool `parser:"| @'sortable'"`
|
||||||
|
Searchable bool `parser:"| @'searchable'"`
|
||||||
|
Thumbnail bool `parser:"| @'thumbnail'"`
|
||||||
|
Default *string `parser:"| ('default' @String)"`
|
||||||
|
Options []string `parser:"| ('options' '[' @String (',' @String)* ']')"`
|
||||||
|
Accept *string `parser:"| ('accept' @String)"`
|
||||||
|
Rows *int `parser:"| ('rows' @Int)"`
|
||||||
|
Format *string `parser:"| ('format' @String)"`
|
||||||
|
Size *string `parser:"| ('size' @String)"`
|
||||||
|
Display *string `parser:"| ('display' @String)"`
|
||||||
|
Value *string `parser:"| ('value' @String)"`
|
||||||
|
Source *string `parser:"| ('source' @String)"`
|
||||||
|
Relates *FieldRelation `parser:"| @@"`
|
||||||
|
Validation *ComponentValidation `parser:"| @@"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field relationship for autocomplete and select fields
|
||||||
|
type FieldRelation struct {
|
||||||
|
Type string `parser:"'relates' 'to' @Ident"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component validation
|
||||||
|
type ComponentValidation struct {
|
||||||
|
Type string `parser:"'validate' @Ident"`
|
||||||
|
Value *string `parser:"@String?"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced WhenCondition with recursive support for both sections and components
|
||||||
|
type WhenCondition struct {
|
||||||
|
Field string `parser:"'when' @Ident"`
|
||||||
|
Operator string `parser:"@('equals' | 'not_equals' | 'contains')"`
|
||||||
|
Value string `parser:"@String"`
|
||||||
|
Fields []ComponentField `parser:"('{' @@*"`
|
||||||
|
Sections []Section `parser:"@@*"` // Can contain sections
|
||||||
|
Components []Component `parser:"@@*"` // Can contain components
|
||||||
|
Buttons []ComponentButton `parser:"@@* '}')?"` // Block-delimited for unambiguous nesting
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified button with flexible attribute ordering
|
||||||
|
type ComponentButton struct {
|
||||||
|
Name string `parser:"'button' @Ident"`
|
||||||
|
Label string `parser:"'label' @String"`
|
||||||
|
Attributes []ComponentButtonAttr `parser:"@@*"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flexible button attribute system - each attribute is a separate alternative
|
||||||
|
type ComponentButtonAttr struct {
|
||||||
|
Style *ComponentButtonStyle `parser:"@@"`
|
||||||
|
Icon *ComponentButtonIcon `parser:"| @@"`
|
||||||
|
Loading *ComponentButtonLoading `parser:"| @@"`
|
||||||
|
Disabled *ComponentButtonDisabled `parser:"| @@"`
|
||||||
|
Confirm *ComponentButtonConfirm `parser:"| @@"`
|
||||||
|
Target *ComponentButtonTarget `parser:"| @@"`
|
||||||
|
Position *ComponentButtonPosition `parser:"| @@"`
|
||||||
|
Via *ComponentButtonVia `parser:"| @@"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual button attribute types
|
||||||
|
type ComponentButtonStyle struct {
|
||||||
|
Value string `parser:"'style' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonIcon struct {
|
||||||
|
Value string `parser:"'icon' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonLoading struct {
|
||||||
|
Value string `parser:"'loading' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonDisabled struct {
|
||||||
|
Value string `parser:"'disabled' 'when' @Ident"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonConfirm struct {
|
||||||
|
Value string `parser:"'confirm' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonTarget struct {
|
||||||
|
Value string `parser:"'target' @Ident"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonPosition struct {
|
||||||
|
Value string `parser:"'position' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonVia struct {
|
||||||
|
Value string `parser:"'via' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseInput(input string) (AST, error) {
|
||||||
|
parser, err := participle.Build[AST](
|
||||||
|
participle.Unquote("String"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return AST{}, err
|
||||||
|
}
|
||||||
|
ast, err := parser.ParseString("", input)
|
||||||
|
if err != nil {
|
||||||
|
return AST{}, err
|
||||||
|
}
|
||||||
|
return *ast, nil
|
||||||
|
}
|
3
lang/lang_test.go
Normal file
3
lang/lang_test.go
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Various parts of the language and parser are tested in specialized files
|
131
lang/parser_entity_test.go
Normal file
131
lang/parser_entity_test.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseEntityDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "entity with enhanced fields and relationships",
|
||||||
|
input: `entity User desc "User management" {
|
||||||
|
id: uuid required unique
|
||||||
|
email: string required validate email validate min_length "5"
|
||||||
|
name: string default "Anonymous"
|
||||||
|
profile_id: uuid relates to Profile as one via "user_id"
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Entity: &Entity{
|
||||||
|
Name: "User",
|
||||||
|
Description: stringPtr("User management"),
|
||||||
|
Fields: []Field{
|
||||||
|
{
|
||||||
|
Name: "id",
|
||||||
|
Type: "uuid",
|
||||||
|
Required: true,
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "email",
|
||||||
|
Type: "string",
|
||||||
|
Required: true,
|
||||||
|
Validations: []Validation{
|
||||||
|
{Type: "email"},
|
||||||
|
{Type: "min_length", Value: stringPtr("5")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "name",
|
||||||
|
Type: "string",
|
||||||
|
Default: stringPtr("Anonymous"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "profile_id",
|
||||||
|
Type: "uuid",
|
||||||
|
Relationship: &Relationship{
|
||||||
|
Type: "Profile",
|
||||||
|
Cardinality: "one",
|
||||||
|
ForeignKey: stringPtr("user_id"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple entity with basic fields",
|
||||||
|
input: `entity Product {
|
||||||
|
id: uuid required unique
|
||||||
|
name: string required
|
||||||
|
price: decimal default "0.00"
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Entity: &Entity{
|
||||||
|
Name: "Product",
|
||||||
|
Fields: []Field{
|
||||||
|
{
|
||||||
|
Name: "id",
|
||||||
|
Type: "uuid",
|
||||||
|
Required: true,
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "name",
|
||||||
|
Type: "string",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "price",
|
||||||
|
Type: "decimal",
|
||||||
|
Default: stringPtr("0.00"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entity without fields block",
|
||||||
|
input: `entity SimpleEntity`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Entity: &Entity{
|
||||||
|
Name: "SimpleEntity",
|
||||||
|
Fields: []Field{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr && !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() mismatch.\nGot: %+v\nWant: %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
103
lang/parser_server_test.go
Normal file
103
lang/parser_server_test.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseServerDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple server definition with block delimiters",
|
||||||
|
input: `server MyApp {
|
||||||
|
host "localhost"
|
||||||
|
port 8080
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Server: &Server{
|
||||||
|
Name: "MyApp",
|
||||||
|
Settings: []ServerSetting{
|
||||||
|
{Host: stringPtr("localhost")},
|
||||||
|
{Port: intPtr(8080)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server with only host setting",
|
||||||
|
input: `server WebApp {
|
||||||
|
host "0.0.0.0"
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Server: &Server{
|
||||||
|
Name: "WebApp",
|
||||||
|
Settings: []ServerSetting{
|
||||||
|
{Host: stringPtr("0.0.0.0")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server with only port setting",
|
||||||
|
input: `server APIServer {
|
||||||
|
port 3000
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Server: &Server{
|
||||||
|
Name: "APIServer",
|
||||||
|
Settings: []ServerSetting{
|
||||||
|
{Port: intPtr(3000)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server without settings block",
|
||||||
|
input: `server SimpleServer`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Server: &Server{
|
||||||
|
Name: "SimpleServer",
|
||||||
|
Settings: []ServerSetting{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr && !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() mismatch.\nGot: %+v\nWant: %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
575
lang/parser_ui_advanced_test.go
Normal file
575
lang/parser_ui_advanced_test.go
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAdvancedUIFeatures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "complex conditional rendering with multiple operators",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for User {
|
||||||
|
field status type select options ["active", "inactive", "pending"]
|
||||||
|
|
||||||
|
when status equals "active" {
|
||||||
|
field last_login type datetime
|
||||||
|
field permissions type multiselect
|
||||||
|
button deactivate label "Deactivate User" style "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
when status not_equals "active" {
|
||||||
|
field reason type textarea placeholder "Reason for status"
|
||||||
|
button activate label "Activate User" style "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
when status contains "pending" {
|
||||||
|
field approval_date type date
|
||||||
|
button approve label "Approve" style "primary"
|
||||||
|
button reject label "Reject" style "danger"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "status",
|
||||||
|
Type: "select",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Options: []string{"active", "inactive", "pending"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
When: &WhenCondition{
|
||||||
|
Field: "status",
|
||||||
|
Operator: "equals",
|
||||||
|
Value: "active",
|
||||||
|
Fields: []ComponentField{
|
||||||
|
{Name: "last_login", Type: "datetime"},
|
||||||
|
{Name: "permissions", Type: "multiselect"},
|
||||||
|
},
|
||||||
|
Buttons: []ComponentButton{
|
||||||
|
{
|
||||||
|
Name: "deactivate",
|
||||||
|
Label: "Deactivate User",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "warning"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
When: &WhenCondition{
|
||||||
|
Field: "status",
|
||||||
|
Operator: "not_equals",
|
||||||
|
Value: "active",
|
||||||
|
Fields: []ComponentField{
|
||||||
|
{
|
||||||
|
Name: "reason",
|
||||||
|
Type: "textarea",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Placeholder: stringPtr("Reason for status")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Buttons: []ComponentButton{
|
||||||
|
{
|
||||||
|
Name: "activate",
|
||||||
|
Label: "Activate User",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "success"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
When: &WhenCondition{
|
||||||
|
Field: "status",
|
||||||
|
Operator: "contains",
|
||||||
|
Value: "pending",
|
||||||
|
Fields: []ComponentField{
|
||||||
|
{Name: "approval_date", Type: "date"},
|
||||||
|
},
|
||||||
|
Buttons: []ComponentButton{
|
||||||
|
{
|
||||||
|
Name: "approve",
|
||||||
|
Label: "Approve",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "reject",
|
||||||
|
Label: "Reject",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "danger"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "field attributes with all possible options",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for Product {
|
||||||
|
field name type text {
|
||||||
|
label "Product Name"
|
||||||
|
placeholder "Enter product name"
|
||||||
|
required
|
||||||
|
default "New Product"
|
||||||
|
validate min_length "3"
|
||||||
|
size "large"
|
||||||
|
display "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
field price type number {
|
||||||
|
label "Price ($)"
|
||||||
|
format "currency"
|
||||||
|
validate min "0"
|
||||||
|
validate max "10000"
|
||||||
|
}
|
||||||
|
|
||||||
|
field category type autocomplete {
|
||||||
|
label "Category"
|
||||||
|
placeholder "Start typing..."
|
||||||
|
relates to Category
|
||||||
|
searchable
|
||||||
|
source "categories/search"
|
||||||
|
}
|
||||||
|
|
||||||
|
field tags type multiselect {
|
||||||
|
label "Tags"
|
||||||
|
options ["electronics", "clothing", "books", "home"]
|
||||||
|
source "tags/popular"
|
||||||
|
}
|
||||||
|
|
||||||
|
field description type richtext {
|
||||||
|
label "Description"
|
||||||
|
rows 10
|
||||||
|
placeholder "Describe your product..."
|
||||||
|
}
|
||||||
|
|
||||||
|
field thumbnail type image {
|
||||||
|
label "Product Image"
|
||||||
|
accept "image/jpeg,image/png"
|
||||||
|
thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
field featured type checkbox {
|
||||||
|
label "Featured Product"
|
||||||
|
default "false"
|
||||||
|
value "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
field availability type select {
|
||||||
|
label "Availability"
|
||||||
|
options ["in_stock", "out_of_stock", "pre_order"]
|
||||||
|
default "in_stock"
|
||||||
|
sortable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("Product"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Product Name")},
|
||||||
|
{Placeholder: stringPtr("Enter product name")},
|
||||||
|
{Required: true},
|
||||||
|
{Default: stringPtr("New Product")},
|
||||||
|
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
|
||||||
|
{Size: stringPtr("large")},
|
||||||
|
{Display: stringPtr("block")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "price",
|
||||||
|
Type: "number",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Price ($)")},
|
||||||
|
{Format: stringPtr("currency")},
|
||||||
|
{Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}},
|
||||||
|
{Validation: &ComponentValidation{Type: "max", Value: stringPtr("10000")}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "category",
|
||||||
|
Type: "autocomplete",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Category")},
|
||||||
|
{Placeholder: stringPtr("Start typing...")},
|
||||||
|
{Relates: &FieldRelation{Type: "Category"}},
|
||||||
|
{Searchable: true},
|
||||||
|
{Source: stringPtr("categories/search")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "tags",
|
||||||
|
Type: "multiselect",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Tags")},
|
||||||
|
{Options: []string{"electronics", "clothing", "books", "home"}},
|
||||||
|
{Source: stringPtr("tags/popular")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "description",
|
||||||
|
Type: "richtext",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Description")},
|
||||||
|
{Rows: intPtr(10)},
|
||||||
|
{Placeholder: stringPtr("Describe your product...")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "thumbnail",
|
||||||
|
Type: "image",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Product Image")},
|
||||||
|
{Accept: stringPtr("image/jpeg,image/png")},
|
||||||
|
{Thumbnail: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "featured",
|
||||||
|
Type: "checkbox",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Featured Product")},
|
||||||
|
{Default: stringPtr("false")},
|
||||||
|
{Value: stringPtr("true")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "availability",
|
||||||
|
Type: "select",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Availability")},
|
||||||
|
{Options: []string{"in_stock", "out_of_stock", "pre_order"}},
|
||||||
|
{Default: stringPtr("in_stock")},
|
||||||
|
{Sortable: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex button configurations",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for Order {
|
||||||
|
field status type select options ["draft", "submitted", "approved"]
|
||||||
|
|
||||||
|
button save label "Save Draft" style "secondary" icon "save" position "left"
|
||||||
|
button submit label "Submit Order" style "primary" icon "send" loading "Submitting..." confirm "Submit this order?"
|
||||||
|
button approve label "Approve" style "success" loading "Approving..." disabled when status confirm "Approve this order?" target approval_modal via "api/orders/approve"
|
||||||
|
button reject label "Reject" style "danger" icon "x" confirm "Are you sure you want to reject this order?"
|
||||||
|
button print label "Print" style "outline" icon "printer" position "right"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("Order"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "status",
|
||||||
|
Type: "select",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Options: []string{"draft", "submitted", "approved"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "save",
|
||||||
|
Label: "Save Draft",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "secondary"}},
|
||||||
|
{Icon: &ComponentButtonIcon{Value: "save"}},
|
||||||
|
{Position: &ComponentButtonPosition{Value: "left"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "submit",
|
||||||
|
Label: "Submit Order",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||||
|
{Icon: &ComponentButtonIcon{Value: "send"}},
|
||||||
|
{Loading: &ComponentButtonLoading{Value: "Submitting..."}},
|
||||||
|
{Confirm: &ComponentButtonConfirm{Value: "Submit this order?"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "approve",
|
||||||
|
Label: "Approve",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "success"}},
|
||||||
|
{Loading: &ComponentButtonLoading{Value: "Approving..."}},
|
||||||
|
{Disabled: &ComponentButtonDisabled{Value: "status"}},
|
||||||
|
{Confirm: &ComponentButtonConfirm{Value: "Approve this order?"}},
|
||||||
|
{Target: &ComponentButtonTarget{Value: "approval_modal"}},
|
||||||
|
{Via: &ComponentButtonVia{Value: "api/orders/approve"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "reject",
|
||||||
|
Label: "Reject",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "danger"}},
|
||||||
|
{Icon: &ComponentButtonIcon{Value: "x"}},
|
||||||
|
{Confirm: &ComponentButtonConfirm{Value: "Are you sure you want to reject this order?"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "print",
|
||||||
|
Label: "Print",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "outline"}},
|
||||||
|
{Icon: &ComponentButtonIcon{Value: "printer"}},
|
||||||
|
{Position: &ComponentButtonPosition{Value: "right"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() got = %v, want = %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFieldValidationTypes(t *testing.T) {
|
||||||
|
validationTypes := []struct {
|
||||||
|
validation string
|
||||||
|
hasValue bool
|
||||||
|
}{
|
||||||
|
{"email", false},
|
||||||
|
{"required", false},
|
||||||
|
{"min_length", true},
|
||||||
|
{"max_length", true},
|
||||||
|
{"min", true},
|
||||||
|
{"max", true},
|
||||||
|
{"pattern", true},
|
||||||
|
{"numeric", false},
|
||||||
|
{"alpha", false},
|
||||||
|
{"alphanumeric", false},
|
||||||
|
{"url", false},
|
||||||
|
{"date", false},
|
||||||
|
{"datetime", false},
|
||||||
|
{"time", false},
|
||||||
|
{"phone", false},
|
||||||
|
{"postal_code", false},
|
||||||
|
{"credit_card", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, vt := range validationTypes {
|
||||||
|
t.Run("validation_"+vt.validation, func(t *testing.T) {
|
||||||
|
var input string
|
||||||
|
if vt.hasValue {
|
||||||
|
input = `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test_field type text validate ` + vt.validation + ` "test_value"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
} else {
|
||||||
|
input = `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test_field type text validate ` + vt.validation + `
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseInput() failed for validation %s: %v", vt.validation, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
|
||||||
|
t.Errorf("ParseInput() failed to parse page for validation %s", vt.validation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := got.Definitions[0].Page
|
||||||
|
if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 {
|
||||||
|
t.Errorf("ParseInput() failed to parse component for validation %s", vt.validation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
element := page.Components[0].Elements[0]
|
||||||
|
if element.Field == nil || len(element.Field.Attributes) != 1 {
|
||||||
|
t.Errorf("ParseInput() failed to parse field attributes for validation %s", vt.validation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attr := element.Field.Attributes[0]
|
||||||
|
if attr.Validation == nil || attr.Validation.Type != vt.validation {
|
||||||
|
t.Errorf("ParseInput() validation type mismatch: got %v, want %s", attr.Validation, vt.validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vt.hasValue && (attr.Validation.Value == nil || *attr.Validation.Value != "test_value") {
|
||||||
|
t.Errorf("ParseInput() validation value mismatch for %s", vt.validation)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConditionalOperators(t *testing.T) {
|
||||||
|
operators := []string{"equals", "not_equals", "contains"}
|
||||||
|
|
||||||
|
for _, op := range operators {
|
||||||
|
t.Run("operator_"+op, func(t *testing.T) {
|
||||||
|
input := `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test_field type text
|
||||||
|
when test_field ` + op + ` "test_value" {
|
||||||
|
field conditional_field type text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
got, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseInput() failed for operator %s: %v", op, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the when condition was parsed correctly
|
||||||
|
page := got.Definitions[0].Page
|
||||||
|
component := page.Components[0]
|
||||||
|
whenElement := component.Elements[1].When
|
||||||
|
|
||||||
|
if whenElement == nil || whenElement.Operator != op {
|
||||||
|
t.Errorf("ParseInput() operator mismatch: got %v, want %s", whenElement, op)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAdvancedUIErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid conditional operator",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
when field invalid_operator "value" {
|
||||||
|
field test type text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing field attribute block closure",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test type text {
|
||||||
|
label "Test"
|
||||||
|
required
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseInput(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
548
lang/parser_ui_component_test.go
Normal file
548
lang/parser_ui_component_test.go
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseComponentDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic component with entity",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component table for User
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "table",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "form component with fields",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for User {
|
||||||
|
field name type text label "Full Name" placeholder "Enter your name" required
|
||||||
|
field email type email label "Email Address" required
|
||||||
|
field bio type textarea rows 5 placeholder "Tell us about yourself"
|
||||||
|
field avatar type file accept "image/*"
|
||||||
|
field role type select options ["admin", "user", "guest"] default "user"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Full Name")},
|
||||||
|
{Placeholder: stringPtr("Enter your name")},
|
||||||
|
{Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "email",
|
||||||
|
Type: "email",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Label: stringPtr("Email Address")},
|
||||||
|
{Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "bio",
|
||||||
|
Type: "textarea",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Rows: intPtr(5)},
|
||||||
|
{Placeholder: stringPtr("Tell us about yourself")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "avatar",
|
||||||
|
Type: "file",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Accept: stringPtr("image/*")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "role",
|
||||||
|
Type: "select",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Options: []string{"admin", "user", "guest"}},
|
||||||
|
{Default: stringPtr("user")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "component with field attributes and validation",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for Product {
|
||||||
|
field name type text required validate min_length "3"
|
||||||
|
field price type number format "currency" validate min "0"
|
||||||
|
field category type autocomplete relates to Category
|
||||||
|
field tags type multiselect source "tags/popular"
|
||||||
|
field description type richtext
|
||||||
|
field featured type checkbox default "false"
|
||||||
|
field thumbnail type image thumbnail
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("Product"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Required: true},
|
||||||
|
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "price",
|
||||||
|
Type: "number",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Format: stringPtr("currency")},
|
||||||
|
{Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "category",
|
||||||
|
Type: "autocomplete",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Relates: &FieldRelation{Type: "Category"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "tags",
|
||||||
|
Type: "multiselect",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Source: stringPtr("tags/popular")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "description",
|
||||||
|
Type: "richtext",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "featured",
|
||||||
|
Type: "checkbox",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Default: stringPtr("false")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "thumbnail",
|
||||||
|
Type: "image",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Thumbnail: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "component with buttons",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for User {
|
||||||
|
field name type text
|
||||||
|
button save label "Save User" style "primary" icon "save"
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
button delete label "Delete" style "danger" confirm "Are you sure?" disabled when is_protected
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "save",
|
||||||
|
Label: "Save User",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||||
|
{Icon: &ComponentButtonIcon{Value: "save"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "cancel",
|
||||||
|
Label: "Cancel",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "secondary"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "delete",
|
||||||
|
Label: "Delete",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "danger"}},
|
||||||
|
{Confirm: &ComponentButtonConfirm{Value: "Are you sure?"}},
|
||||||
|
{Disabled: &ComponentButtonDisabled{Value: "is_protected"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "component with conditional fields",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for User {
|
||||||
|
field account_type type select options ["personal", "business"]
|
||||||
|
when account_type equals "business" {
|
||||||
|
field company_name type text required
|
||||||
|
field tax_id type text
|
||||||
|
button verify_business label "Verify Business"
|
||||||
|
}
|
||||||
|
when account_type equals "personal" {
|
||||||
|
field date_of_birth type date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "account_type",
|
||||||
|
Type: "select",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Options: []string{"personal", "business"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
When: &WhenCondition{
|
||||||
|
Field: "account_type",
|
||||||
|
Operator: "equals",
|
||||||
|
Value: "business",
|
||||||
|
Fields: []ComponentField{
|
||||||
|
{
|
||||||
|
Name: "company_name",
|
||||||
|
Type: "text",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tax_id",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Buttons: []ComponentButton{
|
||||||
|
{
|
||||||
|
Name: "verify_business",
|
||||||
|
Label: "Verify Business",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
When: &WhenCondition{
|
||||||
|
Field: "account_type",
|
||||||
|
Operator: "equals",
|
||||||
|
Value: "personal",
|
||||||
|
Fields: []ComponentField{
|
||||||
|
{
|
||||||
|
Name: "date_of_birth",
|
||||||
|
Type: "date",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "component with nested sections",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component dashboard {
|
||||||
|
section stats type container class "stats-grid" {
|
||||||
|
component metric {
|
||||||
|
field total_users type display value "1,234"
|
||||||
|
field revenue type display format "currency" value "45,678"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section charts type container {
|
||||||
|
component chart for Analytics {
|
||||||
|
data from "analytics/monthly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "dashboard",
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "stats",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Class: stringPtr("stats-grid"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "metric",
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "total_users",
|
||||||
|
Type: "display",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Value: stringPtr("1,234")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "revenue",
|
||||||
|
Type: "display",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Format: stringPtr("currency")},
|
||||||
|
{Value: stringPtr("45,678")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "charts",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "chart",
|
||||||
|
Entity: stringPtr("Analytics"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Attribute: &ComponentAttr{
|
||||||
|
DataSource: stringPtr("analytics/monthly"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseComponentFieldTypes(t *testing.T) {
|
||||||
|
fieldTypes := []string{
|
||||||
|
"text", "email", "password", "number", "date", "datetime", "time",
|
||||||
|
"textarea", "richtext", "select", "multiselect", "checkbox", "radio",
|
||||||
|
"file", "image", "autocomplete", "range", "color", "url", "tel",
|
||||||
|
"hidden", "display", "json", "code",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fieldType := range fieldTypes {
|
||||||
|
t.Run("field_type_"+fieldType, func(t *testing.T) {
|
||||||
|
input := `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test_field type ` + fieldType + `
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
got, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseInput() failed for field type %s: %v", fieldType, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
|
||||||
|
t.Errorf("ParseInput() failed to parse page for field type %s", fieldType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := got.Definitions[0].Page
|
||||||
|
if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 {
|
||||||
|
t.Errorf("ParseInput() failed to parse component for field type %s", fieldType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
element := page.Components[0].Elements[0]
|
||||||
|
if element.Field == nil || element.Field.Type != fieldType {
|
||||||
|
t.Errorf("ParseInput() field type mismatch: got %v, want %s", element.Field, fieldType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseComponentErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing component type",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid field syntax",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field name
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid button syntax",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
button
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseInput(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
300
lang/parser_ui_page_test.go
Normal file
300
lang/parser_ui_page_test.go
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePageDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic page with minimal fields",
|
||||||
|
input: `page Dashboard at "/dashboard" layout main`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Dashboard",
|
||||||
|
Path: "/dashboard",
|
||||||
|
Layout: "main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page with all optional fields",
|
||||||
|
input: `page UserProfile at "/profile" layout main title "User Profile" desc "Manage user profile settings" auth`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "UserProfile",
|
||||||
|
Path: "/profile",
|
||||||
|
Layout: "main",
|
||||||
|
Title: stringPtr("User Profile"),
|
||||||
|
Description: stringPtr("Manage user profile settings"),
|
||||||
|
Auth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page with meta tags",
|
||||||
|
input: `page HomePage at "/" layout main {
|
||||||
|
meta description "Welcome to our application"
|
||||||
|
meta keywords "app, dashboard, management"
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "HomePage",
|
||||||
|
Path: "/",
|
||||||
|
Layout: "main",
|
||||||
|
Meta: []MetaTag{
|
||||||
|
{Name: "description", Content: "Welcome to our application"},
|
||||||
|
{Name: "keywords", Content: "app, dashboard, management"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page with nested sections",
|
||||||
|
input: `page Settings at "/settings" layout main {
|
||||||
|
section tabs type tab {
|
||||||
|
section profile label "Profile" active {
|
||||||
|
component form for User {
|
||||||
|
field name type text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section security label "Security" {
|
||||||
|
component form for User {
|
||||||
|
field password type password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Settings",
|
||||||
|
Path: "/settings",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "tabs",
|
||||||
|
Type: stringPtr("tab"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "profile",
|
||||||
|
Label: stringPtr("Profile"),
|
||||||
|
Active: true,
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "security",
|
||||||
|
Label: stringPtr("Security"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "password",
|
||||||
|
Type: "password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page with modal and panel sections",
|
||||||
|
input: `page ProductList at "/products" layout main {
|
||||||
|
section main type container {
|
||||||
|
component table for Product
|
||||||
|
}
|
||||||
|
|
||||||
|
section editModal type modal trigger "edit-product" {
|
||||||
|
component form for Product {
|
||||||
|
field name type text required
|
||||||
|
button save label "Save Changes" style "primary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section filters type panel position "left" {
|
||||||
|
component form {
|
||||||
|
field category type select
|
||||||
|
field price_range type range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "ProductList",
|
||||||
|
Path: "/products",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "table",
|
||||||
|
Entity: stringPtr("Product"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "editModal",
|
||||||
|
Type: stringPtr("modal"),
|
||||||
|
Trigger: stringPtr("edit-product"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("Product"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "save",
|
||||||
|
Label: "Save Changes",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "filters",
|
||||||
|
Type: stringPtr("panel"),
|
||||||
|
Position: stringPtr("left"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "form",
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "category",
|
||||||
|
Type: "select",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "price_range",
|
||||||
|
Type: "range",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePageErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing layout",
|
||||||
|
input: `page Dashboard at "/dashboard"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing path",
|
||||||
|
input: `page Dashboard layout main`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid path format",
|
||||||
|
input: `page Dashboard at dashboard layout main`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseInput(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
599
lang/parser_ui_section_test.go
Normal file
599
lang/parser_ui_section_test.go
Normal file
@ -0,0 +1,599 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSectionDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic container section",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section main type container
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "section with all attributes",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section sidebar type panel class "sidebar-nav" label "Navigation" trigger "toggle-sidebar" position "left" for User
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "sidebar",
|
||||||
|
Type: stringPtr("panel"),
|
||||||
|
Position: stringPtr("left"),
|
||||||
|
Class: stringPtr("sidebar-nav"),
|
||||||
|
Label: stringPtr("Navigation"),
|
||||||
|
Trigger: stringPtr("toggle-sidebar"),
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tab sections with active state",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section tabs type tab {
|
||||||
|
section overview label "Overview" active
|
||||||
|
section details label "Details"
|
||||||
|
section settings label "Settings"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "tabs",
|
||||||
|
Type: stringPtr("tab"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "overview",
|
||||||
|
Label: stringPtr("Overview"),
|
||||||
|
Active: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "details",
|
||||||
|
Label: stringPtr("Details"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "settings",
|
||||||
|
Label: stringPtr("Settings"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modal section with content",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section userModal type modal trigger "edit-user" {
|
||||||
|
component form for User {
|
||||||
|
field name type text required
|
||||||
|
field email type email required
|
||||||
|
button save label "Save Changes" style "primary"
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "userModal",
|
||||||
|
Type: stringPtr("modal"),
|
||||||
|
Trigger: stringPtr("edit-user"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "email",
|
||||||
|
Type: "email",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "save",
|
||||||
|
Label: "Save Changes",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Button: &ComponentButton{
|
||||||
|
Name: "cancel",
|
||||||
|
Label: "Cancel",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "secondary"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "master-detail sections",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section masterDetail type master {
|
||||||
|
section userList type container {
|
||||||
|
component table for User {
|
||||||
|
fields [name, email]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section userDetail type detail trigger "user-selected" for User {
|
||||||
|
component form for User {
|
||||||
|
field name type text
|
||||||
|
field email type email
|
||||||
|
field bio type textarea
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "masterDetail",
|
||||||
|
Type: stringPtr("master"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "userList",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "table",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Attribute: &ComponentAttr{
|
||||||
|
Fields: []string{"name", "email"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "userDetail",
|
||||||
|
Type: stringPtr("detail"),
|
||||||
|
Trigger: stringPtr("user-selected"),
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "email",
|
||||||
|
Type: "email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "bio",
|
||||||
|
Type: "textarea",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deeply nested sections",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section mainLayout type container {
|
||||||
|
section header type container class "header" {
|
||||||
|
component navbar {
|
||||||
|
field search type text placeholder "Search..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section content type container {
|
||||||
|
section sidebar type panel position "left" {
|
||||||
|
component menu {
|
||||||
|
field navigation type list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section main type container {
|
||||||
|
section tabs type tab {
|
||||||
|
section overview label "Overview" active {
|
||||||
|
component dashboard {
|
||||||
|
field stats type metric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section reports label "Reports" {
|
||||||
|
component table for Report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "mainLayout",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "header",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Class: stringPtr("header"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "navbar",
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "search",
|
||||||
|
Type: "text",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Placeholder: stringPtr("Search...")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "content",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "sidebar",
|
||||||
|
Type: stringPtr("panel"),
|
||||||
|
Position: stringPtr("left"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "menu",
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "navigation",
|
||||||
|
Type: "list",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "main",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "tabs",
|
||||||
|
Type: stringPtr("tab"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "overview",
|
||||||
|
Label: stringPtr("Overview"),
|
||||||
|
Active: true,
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "dashboard",
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "stats",
|
||||||
|
Type: "metric",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "reports",
|
||||||
|
Label: stringPtr("Reports"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "table",
|
||||||
|
Entity: stringPtr("Report"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "section with conditional content",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section adminPanel type container {
|
||||||
|
when user_role equals "admin" {
|
||||||
|
section userManagement type container {
|
||||||
|
component table for User
|
||||||
|
}
|
||||||
|
section systemSettings type container {
|
||||||
|
component form for Settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "adminPanel",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
When: &WhenCondition{
|
||||||
|
Field: "user_role",
|
||||||
|
Operator: "equals",
|
||||||
|
Value: "admin",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "userManagement",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "table",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "systemSettings",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("Settings"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSectionTypes(t *testing.T) {
|
||||||
|
sectionTypes := []string{
|
||||||
|
"container", "tab", "panel", "modal", "master", "detail",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sectionType := range sectionTypes {
|
||||||
|
t.Run("section_type_"+sectionType, func(t *testing.T) {
|
||||||
|
input := `page Test at "/test" layout main {
|
||||||
|
section test_section type ` + sectionType + `
|
||||||
|
}`
|
||||||
|
|
||||||
|
got, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseInput() failed for section type %s: %v", sectionType, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
|
||||||
|
t.Errorf("ParseInput() failed to parse page for section type %s", sectionType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := got.Definitions[0].Page
|
||||||
|
if len(page.Sections) != 1 {
|
||||||
|
t.Errorf("ParseInput() failed to parse section for type %s", sectionType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
section := page.Sections[0]
|
||||||
|
if section.Type == nil || *section.Type != sectionType {
|
||||||
|
t.Errorf("ParseInput() section type mismatch: got %v, want %s", section.Type, sectionType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSectionErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing section name",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section type container
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid section type",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section test type invalid_type
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unclosed section block",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section test type container {
|
||||||
|
component form
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseInput(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
59
lang/test_ast_comparisons.go
Normal file
59
lang/test_ast_comparisons.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// AST and definition comparison functions for parser tests
|
||||||
|
|
||||||
|
// Custom comparison functions (simplified for the new structure)
|
||||||
|
func astEqual(got, want AST) bool {
|
||||||
|
if len(got.Definitions) != len(want.Definitions) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range got.Definitions {
|
||||||
|
if !definitionEqual(got.Definitions[i], want.Definitions[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func definitionEqual(got, want Definition) bool {
|
||||||
|
// Server comparison
|
||||||
|
if (got.Server == nil) != (want.Server == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Server != nil && want.Server != nil {
|
||||||
|
if !serverEqual(*got.Server, *want.Server) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity comparison
|
||||||
|
if (got.Entity == nil) != (want.Entity == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Entity != nil && want.Entity != nil {
|
||||||
|
if !entityEqual(*got.Entity, *want.Entity) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint comparison
|
||||||
|
if (got.Endpoint == nil) != (want.Endpoint == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Endpoint != nil && want.Endpoint != nil {
|
||||||
|
if !endpointEqual(*got.Endpoint, *want.Endpoint) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page comparison (enhanced)
|
||||||
|
if (got.Page == nil) != (want.Page == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Page != nil && want.Page != nil {
|
||||||
|
return pageEqual(*got.Page, *want.Page)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
46
lang/test_comparison_utils.go
Normal file
46
lang/test_comparison_utils.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Basic comparison utilities for parser tests
|
||||||
|
|
||||||
|
// Helper functions for creating pointers
|
||||||
|
func stringPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(i int) *int {
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer comparison functions
|
||||||
|
func stringPtrEqual(got, want *string) bool {
|
||||||
|
if (got == nil) != (want == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got != nil && want != nil {
|
||||||
|
return *got == *want
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtrEqual(got, want *int) bool {
|
||||||
|
if (got == nil) != (want == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got != nil && want != nil {
|
||||||
|
return *got == *want
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice comparison functions
|
||||||
|
func stringSliceEqual(got, want []string) bool {
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, s := range got {
|
||||||
|
if s != want[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
69
lang/test_field_comparisons.go
Normal file
69
lang/test_field_comparisons.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Field and validation comparison functions for parser tests
|
||||||
|
|
||||||
|
func fieldEqual(got, want Field) bool {
|
||||||
|
if got.Name != want.Name || got.Type != want.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Required != want.Required || got.Unique != want.Unique || got.Index != want.Index {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Default, want.Default) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Validations) != len(want.Validations) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, validation := range got.Validations {
|
||||||
|
if !validationEqual(validation, want.Validations[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (got.Relationship == nil) != (want.Relationship == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Relationship != nil && want.Relationship != nil {
|
||||||
|
if !relationshipEqual(*got.Relationship, *want.Relationship) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validationEqual(got, want Validation) bool {
|
||||||
|
return got.Type == want.Type && stringPtrEqual(got.Value, want.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func relationshipEqual(got, want Relationship) bool {
|
||||||
|
return got.Type == want.Type &&
|
||||||
|
got.Cardinality == want.Cardinality &&
|
||||||
|
stringPtrEqual(got.ForeignKey, want.ForeignKey) &&
|
||||||
|
stringPtrEqual(got.Through, want.Through)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldRelationEqual(got, want *FieldRelation) bool {
|
||||||
|
if (got == nil) != (want == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got != nil && want != nil {
|
||||||
|
return got.Type == want.Type
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentValidationEqual(got, want *ComponentValidation) bool {
|
||||||
|
if (got == nil) != (want == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got != nil && want != nil {
|
||||||
|
return got.Type == want.Type && stringPtrEqual(got.Value, want.Value)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
57
lang/test_server_entity_comparisons.go
Normal file
57
lang/test_server_entity_comparisons.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Server and entity comparison functions for parser tests
|
||||||
|
|
||||||
|
func serverEqual(got, want Server) bool {
|
||||||
|
if got.Name != want.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Settings) != len(want.Settings) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, setting := range got.Settings {
|
||||||
|
if !serverSettingEqual(setting, want.Settings[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverSettingEqual(got, want ServerSetting) bool {
|
||||||
|
return stringPtrEqual(got.Host, want.Host) && intPtrEqual(got.Port, want.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func entityEqual(got, want Entity) bool {
|
||||||
|
if got.Name != want.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Description, want.Description) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Fields) != len(want.Fields) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, field := range got.Fields {
|
||||||
|
if !fieldEqual(field, want.Fields[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpointEqual(got, want Endpoint) bool {
|
||||||
|
return got.Method == want.Method &&
|
||||||
|
got.Path == want.Path &&
|
||||||
|
stringPtrEqual(got.Entity, want.Entity) &&
|
||||||
|
stringPtrEqual(got.Description, want.Description) &&
|
||||||
|
got.Auth == want.Auth &&
|
||||||
|
stringPtrEqual(got.CustomLogic, want.CustomLogic)
|
||||||
|
// TODO: Add params and response comparison if needed
|
||||||
|
}
|
421
lang/test_ui_comparisons.go
Normal file
421
lang/test_ui_comparisons.go
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Page and UI component comparison functions for parser tests
|
||||||
|
|
||||||
|
func pageEqual(got, want Page) bool {
|
||||||
|
if got.Name != want.Name || got.Path != want.Path || got.Layout != want.Layout {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Title, want.Title) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Description, want.Description) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Auth != want.Auth {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare meta tags
|
||||||
|
if len(got.Meta) != len(want.Meta) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, meta := range got.Meta {
|
||||||
|
if !metaTagEqual(meta, want.Meta[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare sections (unified model)
|
||||||
|
if len(got.Sections) != len(want.Sections) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, section := range got.Sections {
|
||||||
|
if !sectionEqual(section, want.Sections[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare components
|
||||||
|
if len(got.Components) != len(want.Components) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, component := range got.Components {
|
||||||
|
if !componentEqual(component, want.Components[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func metaTagEqual(got, want MetaTag) bool {
|
||||||
|
return got.Name == want.Name && got.Content == want.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionEqual(got, want Section) bool {
|
||||||
|
if got.Name != want.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Type, want.Type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Class, want.Class) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Label, want.Label) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Active != want.Active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Trigger, want.Trigger) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Position, want.Position) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Entity, want.Entity) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract different element types from the unified elements
|
||||||
|
gotAttributes := extractSectionAttributes(got.Elements)
|
||||||
|
gotComponents := extractSectionComponents(got.Elements)
|
||||||
|
gotSections := extractSectionSections(got.Elements)
|
||||||
|
gotWhen := extractSectionWhen(got.Elements)
|
||||||
|
|
||||||
|
wantAttributes := extractSectionAttributes(want.Elements)
|
||||||
|
wantComponents := extractSectionComponents(want.Elements)
|
||||||
|
wantSections := extractSectionSections(want.Elements)
|
||||||
|
wantWhen := extractSectionWhen(want.Elements)
|
||||||
|
|
||||||
|
// Compare attributes
|
||||||
|
if len(gotAttributes) != len(wantAttributes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, attr := range gotAttributes {
|
||||||
|
if !sectionAttributeEqual(attr, wantAttributes[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare components
|
||||||
|
if len(gotComponents) != len(wantComponents) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, comp := range gotComponents {
|
||||||
|
if !componentEqual(comp, wantComponents[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare nested sections
|
||||||
|
if len(gotSections) != len(wantSections) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, sect := range gotSections {
|
||||||
|
if !sectionEqual(sect, wantSections[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare when conditions
|
||||||
|
if len(gotWhen) != len(wantWhen) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, when := range gotWhen {
|
||||||
|
if !whenConditionEqual(when, wantWhen[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to extract different element types from unified elements
|
||||||
|
func extractSectionAttributes(elements []SectionElement) []SectionAttribute {
|
||||||
|
var attrs []SectionAttribute
|
||||||
|
for _, elem := range elements {
|
||||||
|
if elem.Attribute != nil {
|
||||||
|
attrs = append(attrs, *elem.Attribute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSectionComponents(elements []SectionElement) []Component {
|
||||||
|
var comps []Component
|
||||||
|
for _, elem := range elements {
|
||||||
|
if elem.Component != nil {
|
||||||
|
comps = append(comps, *elem.Component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return comps
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSectionSections(elements []SectionElement) []Section {
|
||||||
|
var sects []Section
|
||||||
|
for _, elem := range elements {
|
||||||
|
if elem.Section != nil {
|
||||||
|
sects = append(sects, *elem.Section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sects
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSectionWhen(elements []SectionElement) []WhenCondition {
|
||||||
|
var whens []WhenCondition
|
||||||
|
for _, elem := range elements {
|
||||||
|
if elem.When != nil {
|
||||||
|
whens = append(whens, *elem.When)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return whens
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionAttributeEqual(got, want SectionAttribute) bool {
|
||||||
|
return stringPtrEqual(got.DataSource, want.DataSource) &&
|
||||||
|
stringPtrEqual(got.Style, want.Style) &&
|
||||||
|
stringPtrEqual(got.Classes, want.Classes) &&
|
||||||
|
intPtrEqual(got.Size, want.Size) &&
|
||||||
|
stringPtrEqual(got.Theme, want.Theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentEqual(got, want Component) bool {
|
||||||
|
if got.Type != want.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Entity, want.Entity) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Elements) != len(want.Elements) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, elem := range got.Elements {
|
||||||
|
if !componentElementEqual(elem, want.Elements[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentElementEqual(got, want ComponentElement) bool {
|
||||||
|
// Compare attributes
|
||||||
|
if (got.Attribute == nil) != (want.Attribute == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Attribute != nil && want.Attribute != nil {
|
||||||
|
if !componentAttrEqual(*got.Attribute, *want.Attribute) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fields
|
||||||
|
if (got.Field == nil) != (want.Field == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Field != nil && want.Field != nil {
|
||||||
|
if !componentFieldEqual(*got.Field, *want.Field) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare sections
|
||||||
|
if (got.Section == nil) != (want.Section == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Section != nil && want.Section != nil {
|
||||||
|
if !sectionEqual(*got.Section, *want.Section) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare buttons
|
||||||
|
if (got.Button == nil) != (want.Button == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Button != nil && want.Button != nil {
|
||||||
|
if !componentButtonEqual(*got.Button, *want.Button) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare when conditions
|
||||||
|
if (got.When == nil) != (want.When == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.When != nil && want.When != nil {
|
||||||
|
if !whenConditionEqual(*got.When, *want.When) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentAttrEqual(got, want ComponentAttr) bool {
|
||||||
|
return stringPtrEqual(got.DataSource, want.DataSource) &&
|
||||||
|
stringSliceEqual(got.Fields, want.Fields) &&
|
||||||
|
stringSliceEqual(got.Actions, want.Actions) &&
|
||||||
|
stringPtrEqual(got.Style, want.Style) &&
|
||||||
|
stringPtrEqual(got.Classes, want.Classes) &&
|
||||||
|
intPtrEqual(got.PageSize, want.PageSize) &&
|
||||||
|
got.Validate == want.Validate
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentFieldEqual(got, want ComponentField) bool {
|
||||||
|
if got.Name != want.Name || got.Type != want.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Attributes) != len(want.Attributes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, attr := range got.Attributes {
|
||||||
|
if !componentFieldAttributeEqual(attr, want.Attributes[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentFieldAttributeEqual(got, want ComponentFieldAttribute) bool {
|
||||||
|
return stringPtrEqual(got.Label, want.Label) &&
|
||||||
|
stringPtrEqual(got.Placeholder, want.Placeholder) &&
|
||||||
|
got.Required == want.Required &&
|
||||||
|
got.Sortable == want.Sortable &&
|
||||||
|
got.Searchable == want.Searchable &&
|
||||||
|
got.Thumbnail == want.Thumbnail &&
|
||||||
|
stringPtrEqual(got.Default, want.Default) &&
|
||||||
|
stringSliceEqual(got.Options, want.Options) &&
|
||||||
|
stringPtrEqual(got.Accept, want.Accept) &&
|
||||||
|
intPtrEqual(got.Rows, want.Rows) &&
|
||||||
|
stringPtrEqual(got.Format, want.Format) &&
|
||||||
|
stringPtrEqual(got.Size, want.Size) &&
|
||||||
|
stringPtrEqual(got.Display, want.Display) &&
|
||||||
|
stringPtrEqual(got.Value, want.Value) &&
|
||||||
|
stringPtrEqual(got.Source, want.Source) &&
|
||||||
|
fieldRelationEqual(got.Relates, want.Relates) &&
|
||||||
|
componentValidationEqual(got.Validation, want.Validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentButtonEqual(got, want ComponentButton) bool {
|
||||||
|
if got.Name != want.Name || got.Label != want.Label {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract attributes from both buttons for comparison
|
||||||
|
gotStyle, gotIcon, gotLoading, gotDisabled, gotConfirm, gotTarget, gotPosition, gotVia := extractButtonAttributesNew(got.Attributes)
|
||||||
|
wantStyle, wantIcon, wantLoading, wantDisabled, wantConfirm, wantTarget, wantPosition, wantVia := extractButtonAttributesNew(want.Attributes)
|
||||||
|
|
||||||
|
return stringPtrEqual(gotStyle, wantStyle) &&
|
||||||
|
stringPtrEqual(gotIcon, wantIcon) &&
|
||||||
|
stringPtrEqual(gotLoading, wantLoading) &&
|
||||||
|
stringPtrEqual(gotDisabled, wantDisabled) &&
|
||||||
|
stringPtrEqual(gotConfirm, wantConfirm) &&
|
||||||
|
stringPtrEqual(gotTarget, wantTarget) &&
|
||||||
|
stringPtrEqual(gotPosition, wantPosition) &&
|
||||||
|
stringPtrEqual(gotVia, wantVia)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to extract button attributes from the new structure
|
||||||
|
func extractButtonAttributesNew(attrs []ComponentButtonAttr) (*string, *string, *string, *string, *string, *string, *string, *string) {
|
||||||
|
var style, icon, loading, disabled, confirm, target, position, via *string
|
||||||
|
|
||||||
|
for _, attr := range attrs {
|
||||||
|
if attr.Style != nil {
|
||||||
|
style = &attr.Style.Value
|
||||||
|
}
|
||||||
|
if attr.Icon != nil {
|
||||||
|
icon = &attr.Icon.Value
|
||||||
|
}
|
||||||
|
if attr.Loading != nil {
|
||||||
|
loading = &attr.Loading.Value
|
||||||
|
}
|
||||||
|
if attr.Disabled != nil {
|
||||||
|
disabled = &attr.Disabled.Value
|
||||||
|
}
|
||||||
|
if attr.Confirm != nil {
|
||||||
|
confirm = &attr.Confirm.Value
|
||||||
|
}
|
||||||
|
if attr.Target != nil {
|
||||||
|
target = &attr.Target.Value
|
||||||
|
}
|
||||||
|
if attr.Position != nil {
|
||||||
|
position = &attr.Position.Value
|
||||||
|
}
|
||||||
|
if attr.Via != nil {
|
||||||
|
via = &attr.Via.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return style, icon, loading, disabled, confirm, target, position, via
|
||||||
|
}
|
||||||
|
|
||||||
|
func whenConditionEqual(got, want WhenCondition) bool {
|
||||||
|
if got.Field != want.Field || got.Operator != want.Operator || got.Value != want.Value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fields
|
||||||
|
if len(got.Fields) != len(want.Fields) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, field := range got.Fields {
|
||||||
|
if !componentFieldEqual(field, want.Fields[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare sections
|
||||||
|
if len(got.Sections) != len(want.Sections) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, section := range got.Sections {
|
||||||
|
if !sectionEqual(section, want.Sections[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare components
|
||||||
|
if len(got.Components) != len(want.Components) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, component := range got.Components {
|
||||||
|
if !componentEqual(component, want.Components[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare buttons
|
||||||
|
if len(got.Buttons) != len(want.Buttons) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, button := range got.Buttons {
|
||||||
|
if !componentButtonEqual(button, want.Buttons[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
Reference in New Issue
Block a user