Compare commits
16 Commits
e71b1c3a23
...
dsl
Author | SHA1 | Date | |
---|---|---|---|
6dfe71894d | |||
a899fd6c4d | |||
b82e22c38d | |||
88d757546a | |||
0bccd28134 | |||
29314de3dd | |||
7b84b7e372 | |||
99551887ea | |||
69f507f176 | |||
c6f14e1787 | |||
ca0736b92c | |||
48579844f3 | |||
382129d2bb | |||
23e84c263d | |||
d36e1bfd86 | |||
cf3ad736b7 |
12
.github/copilot-instructions.md
vendored
Normal file
12
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
For terminal commands use commands that are compatible with Windows GitBash.
|
||||
|
||||
If building, running or testing changes to the CLI program, use this command to build local changes:
|
||||
`go build -o masonry.exe ./cmd/cli/`
|
||||
|
||||
You can assume your working directory is `~/src/masonry`
|
||||
|
||||
Do not delete or replace files if you can't read them. Sometimes there are bugs with your file system integration. The user will want to fix any integration issue you may encounter.
|
||||
|
||||
When adding tests, always write tests that exercise the code that ends up in the product. Do not re-implement functions just to run tests against. Refactoring code to make it more testable is acceptable.
|
||||
|
||||
As we add features to Masonry, adding fully implemented examples of using those features as new directories in the `examples` directory under their own self-contained directory with a readme.md is highly encouraged. Similar to the libp2p project.
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/masonry.exe
|
||||
/.idea/copilotDiffState.xml
|
||||
/examples/react-app-generator/generated-blog-app/node_modules/
|
||||
/examples/react-app-generator/generated-app/node_modules/
|
||||
/examples/react-app-generator/generated-app/
|
||||
/examples/custom-js-functions/generated-app/
|
78
.idea/copilotDiffState.xml
generated
78
.idea/copilotDiffState.xml
generated
File diff suppressed because one or more lines are too long
@ -2,18 +2,22 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
commands := []*cli.Command{
|
||||
createCmd(),
|
||||
generateCmd(),
|
||||
generateMultiCmd(), // New command for multi-file template generation
|
||||
webappCmd(),
|
||||
tailwindCmd(),
|
||||
setupCmd(),
|
||||
vueGenCmd(),
|
||||
serveCmd(), // New command for server interpreter
|
||||
templateCmd(), // New command for template interpreter
|
||||
}
|
||||
|
||||
app := &cli.App{
|
||||
|
@ -5,15 +5,22 @@ import (
|
||||
"embed"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
vue_gen "masonry/vue-gen"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/alecthomas/participle/v2"
|
||||
|
||||
"masonry/interpreter"
|
||||
"masonry/lang"
|
||||
)
|
||||
|
||||
//go:embed templates/proto/application.proto.tmpl
|
||||
@ -156,104 +163,143 @@ func generateCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "Generate code from proto files",
|
||||
Usage: "Generate code from proto files or Masonry files",
|
||||
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 {
|
||||
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
|
||||
// Default action - generate proto code for backward compatibility
|
||||
return generateProtoCode()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return &cli.Command{
|
||||
Name: "webapp",
|
||||
@ -368,3 +414,383 @@ 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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func templateCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "template",
|
||||
Aliases: []string{"tmpl"},
|
||||
Usage: "Generate code from templates using Masonry DSL",
|
||||
Description: "This command parses a Masonry file and applies template files to generate code.",
|
||||
Category: "generator",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "input",
|
||||
Usage: "Path to the Masonry input file",
|
||||
Required: true,
|
||||
Aliases: []string{"i"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "template",
|
||||
Usage: "Path to a single template file",
|
||||
Aliases: []string{"t"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "template-dir",
|
||||
Usage: "Path to a directory containing template files",
|
||||
Aliases: []string{"d"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "root-template",
|
||||
Usage: "Name of the root template file when using template-dir (defaults to main.tmpl)",
|
||||
Value: "main.tmpl",
|
||||
Aliases: []string{"r"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Usage: "Output file path (if not specified, prints to stdout)",
|
||||
Aliases: []string{"o"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
inputFile := c.String("input")
|
||||
templateFile := c.String("template")
|
||||
templateDir := c.String("template-dir")
|
||||
rootTemplate := c.String("root-template")
|
||||
outputFile := c.String("output")
|
||||
|
||||
// Validate input parameters
|
||||
if templateFile == "" && templateDir == "" {
|
||||
return fmt.Errorf("either --template or --template-dir must be specified")
|
||||
}
|
||||
if templateFile != "" && templateDir != "" {
|
||||
return fmt.Errorf("cannot specify both --template and --template-dir")
|
||||
}
|
||||
|
||||
// Set default template directory and root template if none specified
|
||||
if templateFile == "" && templateDir == "" {
|
||||
templateDir = "./lang_templates"
|
||||
rootTemplate = "basic_go_server.tmpl"
|
||||
}
|
||||
|
||||
// Create template templateInterpreter
|
||||
templateInterpreter := interpreter.NewTemplateInterpreter()
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
if templateFile != "" {
|
||||
// Use single template file
|
||||
result, err = templateInterpreter.InterpretFromFile(inputFile, templateFile)
|
||||
} else {
|
||||
// Use template directory
|
||||
result, err = templateInterpreter.InterpretFromDirectory(inputFile, templateDir, rootTemplate)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error generating code: %w", err)
|
||||
}
|
||||
|
||||
// Output result
|
||||
if outputFile != "" {
|
||||
err = os.WriteFile(outputFile, []byte(result), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing output file: %w", err)
|
||||
}
|
||||
fmt.Printf("Generated code written to: %s\n", outputFile)
|
||||
} else {
|
||||
fmt.Print(result)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func generateMultiCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "generate-multi",
|
||||
Aliases: []string{"gen-multi"},
|
||||
Usage: "Generate multiple files using a template manifest",
|
||||
Description: "This command parses a Masonry file and applies a template manifest to generate multiple output files with per-item iteration support.",
|
||||
Category: "generator",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "input",
|
||||
Usage: "Path to the Masonry input file",
|
||||
Required: true,
|
||||
Aliases: []string{"i"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "template-dir",
|
||||
Usage: "Path to the directory containing template files",
|
||||
Required: true,
|
||||
Aliases: []string{"d"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "manifest",
|
||||
Usage: "Name of the manifest file (defaults to manifest.yaml)",
|
||||
Value: "manifest.yaml",
|
||||
Aliases: []string{"m"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output-dir",
|
||||
Usage: "Output directory for generated files (defaults to ./generated)",
|
||||
Value: "./generated",
|
||||
Aliases: []string{"o"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dry-run",
|
||||
Usage: "Show what files would be generated without writing them",
|
||||
Value: false,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "clean",
|
||||
Usage: "Clean output directory before generating files",
|
||||
Value: false,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
inputFile := c.String("input")
|
||||
templateDir := c.String("template-dir")
|
||||
manifestFile := c.String("manifest")
|
||||
outputDir := c.String("output-dir")
|
||||
dryRun := c.Bool("dry-run")
|
||||
clean := c.Bool("clean")
|
||||
|
||||
// Validate input files exist
|
||||
if _, err := os.Stat(inputFile); os.IsNotExist(err) {
|
||||
return fmt.Errorf("input file does not exist: %s", inputFile)
|
||||
}
|
||||
if _, err := os.Stat(templateDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("template directory does not exist: %s", templateDir)
|
||||
}
|
||||
manifestPath := filepath.Join(templateDir, manifestFile)
|
||||
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("manifest file does not exist: %s", manifestPath)
|
||||
}
|
||||
|
||||
fmt.Printf("Processing multi-file generation...\n")
|
||||
fmt.Printf("Input: %s\n", inputFile)
|
||||
fmt.Printf("Template Dir: %s\n", templateDir)
|
||||
fmt.Printf("Manifest: %s\n", manifestFile)
|
||||
fmt.Printf("Output Dir: %s\n", outputDir)
|
||||
|
||||
// Clean output directory if requested
|
||||
if clean && !dryRun {
|
||||
if _, err := os.Stat(outputDir); !os.IsNotExist(err) {
|
||||
fmt.Printf("Cleaning output directory: %s\n", outputDir)
|
||||
err := os.RemoveAll(outputDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error cleaning output directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create template interpreter
|
||||
templateInterpreter := interpreter.NewTemplateInterpreter()
|
||||
|
||||
// Generate multiple files using manifest
|
||||
outputs, err := templateInterpreter.InterpretToFiles(inputFile, templateDir, manifestFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error generating files: %w", err)
|
||||
}
|
||||
|
||||
if len(outputs) == 0 {
|
||||
fmt.Println("No files were generated (check your manifest conditions)")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Generated %d file(s):\n", len(outputs))
|
||||
|
||||
if dryRun {
|
||||
// Dry run - just show what would be generated
|
||||
for filePath, content := range outputs {
|
||||
fmt.Printf(" [DRY-RUN] %s (%d bytes)\n", filePath, len(content))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write all output files
|
||||
for filePath, content := range outputs {
|
||||
fullPath := filepath.Join(outputDir, filePath)
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
dir := filepath.Dir(fullPath)
|
||||
err := os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
err = os.WriteFile(fullPath, []byte(content), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing file %s: %w", fullPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf(" Generated: %s\n", fullPath)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully generated %d file(s) in %s\n", len(outputs), outputDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -68,15 +68,9 @@ func main() {
|
||||
// -- end of local database code --
|
||||
|
||||
// Uncomment these lines if you need automatic migration
|
||||
// err = gormDB.AutoMigrate(&pb.UserORM{})
|
||||
// err = gormDB.AutoMigrate(&pb.UserORM{}, &pb.ProductORM{})
|
||||
// if err != nil {
|
||||
// logger.Printf("failed to migrate user: %s", err)
|
||||
// log.Fatalln(err)
|
||||
// return
|
||||
// }
|
||||
// err = gormDB.AutoMigrate(&pb.ProductORM{})
|
||||
// if err != nil {
|
||||
// logger.Printf("failed to migrate product: %s", err)
|
||||
// logger.Printf("failed to auto migrate database: %s", err)
|
||||
// log.Fatalln(err)
|
||||
// return
|
||||
// }
|
||||
|
31
examples/custom-js-functions/blog-example.masonry
Normal file
31
examples/custom-js-functions/blog-example.masonry
Normal file
@ -0,0 +1,31 @@
|
||||
entity User {
|
||||
name: string required
|
||||
email: string required unique
|
||||
password: string required
|
||||
bio: text
|
||||
createdAt: timestamp
|
||||
updatedAt: timestamp
|
||||
}
|
||||
|
||||
entity Post {
|
||||
title: string required
|
||||
slug: string required unique
|
||||
content: text required
|
||||
published: boolean
|
||||
authorId: uuid required relates to User as one via "authorId"
|
||||
createdAt: timestamp
|
||||
updatedAt: timestamp
|
||||
}
|
||||
|
||||
entity Comment {
|
||||
content: text required
|
||||
authorId: uuid required relates to User as one via "authorId"
|
||||
postId: uuid required relates to Post as one via "postId"
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
server BlogAPI {
|
||||
host "localhost"
|
||||
port 3000
|
||||
database_url env "DATABASE_URL" default "postgres://localhost/blog"
|
||||
}
|
68
examples/custom-js-functions/readme.md
Normal file
68
examples/custom-js-functions/readme.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Custom JavaScript Functions Example
|
||||
|
||||
This example demonstrates how to use custom JavaScript functions in Masonry templates using the Otto JavaScript interpreter. You can define custom template functions directly in your `manifest.yaml` files, allowing for dynamic code generation that can be customized without recompiling the CLI tool.
|
||||
|
||||
## Overview
|
||||
|
||||
The custom JavaScript functions feature allows you to:
|
||||
- Define JavaScript functions in your `manifest.yaml` files
|
||||
- Use these functions in your templates just like built-in template functions
|
||||
- Perform complex string manipulations, data transformations, and logic
|
||||
- Extend template functionality dynamically
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Define Functions**: Add a `functions` section to your `manifest.yaml` file
|
||||
2. **JavaScript Code**: Each function is written in JavaScript and must define a `main()` function
|
||||
3. **Template Usage**: Use the custom functions in your templates like any other template function
|
||||
4. **Otto Execution**: The Otto JavaScript interpreter executes your functions at template generation time
|
||||
|
||||
## Function Structure
|
||||
|
||||
Each custom function must follow this structure:
|
||||
|
||||
```javascript
|
||||
function main() {
|
||||
// Your custom logic here
|
||||
// Access arguments via the global 'args' array: args[0], args[1], etc.
|
||||
// Or via individual variables: arg0, arg1, etc.
|
||||
|
||||
return "your result";
|
||||
}
|
||||
```
|
||||
|
||||
## Example Files
|
||||
|
||||
- `blog-example.masonry` - Sample Masonry file defining a blog application
|
||||
- `templates/manifest.yaml` - Manifest with custom JavaScript functions
|
||||
- `templates/model.tmpl` - Template using the custom functions
|
||||
- `templates/controller.tmpl` - Another template demonstrating function usage
|
||||
|
||||
## Running the Example
|
||||
|
||||
```bash
|
||||
# From the masonry project root
|
||||
./masonry.exe generate templates examples/custom-js-functions/blog-example.masonry examples/custom-js-functions/templates manifest.yaml
|
||||
```
|
||||
|
||||
## Custom Functions in This Example
|
||||
|
||||
### `generateSlug`
|
||||
Converts a title to a URL-friendly slug:
|
||||
- Input: "My Blog Post Title"
|
||||
- Output: "my-blog-post-title"
|
||||
|
||||
### `pluralize`
|
||||
Converts singular words to plural:
|
||||
- Input: "post"
|
||||
- Output: "posts"
|
||||
|
||||
### `generateValidation`
|
||||
Creates validation rules based on field type:
|
||||
- Input: field type
|
||||
- Output: appropriate validation code
|
||||
|
||||
### `formatComment`
|
||||
Generates formatted code comments:
|
||||
- Input: text
|
||||
- Output: properly formatted comment block
|
97
examples/custom-js-functions/templates/controller.tmpl
Normal file
97
examples/custom-js-functions/templates/controller.tmpl
Normal file
@ -0,0 +1,97 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"your-app/models"
|
||||
)
|
||||
|
||||
{{formatComment (printf "Controller for %s entity\nGenerated with custom JavaScript template functions" .Entity.Name)}}
|
||||
type {{.Entity.Name | title}}Controller struct {
|
||||
// Add your dependencies here (e.g., database, services)
|
||||
}
|
||||
|
||||
{{formatComment "Create a new controller instance"}}
|
||||
func New{{.Entity.Name | title}}Controller() *{{.Entity.Name | title}}Controller {
|
||||
return &{{.Entity.Name | title}}Controller{}
|
||||
}
|
||||
|
||||
{{formatComment "GET /{{pluralize (.Entity.Name | lower)}}"}}
|
||||
func (c *{{.Entity.Name | title}}Controller) List{{pluralize (.Entity.Name | title)}}(w http.ResponseWriter, r *http.Request) {
|
||||
// Implementation for listing {{pluralize (.Entity.Name | lower)}}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Example response
|
||||
{{pluralize (.Entity.Name | lower)}} := []models.{{.Entity.Name | title}}{}
|
||||
json.NewEncoder(w).Encode({{pluralize (.Entity.Name | lower)}})
|
||||
}
|
||||
|
||||
{{formatComment "GET /{{pluralize (.Entity.Name | lower)}}/:id"}}
|
||||
func (c *{{.Entity.Name | title}}Controller) Get{{.Entity.Name | title}}(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
// Convert id and fetch {{.Entity.Name | lower}}
|
||||
_ = id // TODO: implement fetch logic
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// json.NewEncoder(w).Encode({{.Entity.Name | lower}})
|
||||
}
|
||||
|
||||
{{formatComment "POST /{{pluralize (.Entity.Name | lower)}}"}}
|
||||
func (c *{{.Entity.Name | title}}Controller) Create{{.Entity.Name | title}}(w http.ResponseWriter, r *http.Request) {
|
||||
var {{.Entity.Name | lower}} models.{{.Entity.Name | title}}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&{{.Entity.Name | lower}}); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := {{.Entity.Name | lower}}.Validate(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Save to database
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode({{.Entity.Name | lower}})
|
||||
}
|
||||
|
||||
{{formatComment "PUT /{{pluralize (.Entity.Name | lower)}}/:id"}}
|
||||
func (c *{{.Entity.Name | title}}Controller) Update{{.Entity.Name | title}}(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
var {{.Entity.Name | lower}} models.{{.Entity.Name | title}}
|
||||
if err := json.NewDecoder(r.Body).Decode(&{{.Entity.Name | lower}}); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := {{.Entity.Name | lower}}.Validate(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Update in database using id
|
||||
_ = id
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode({{.Entity.Name | lower}})
|
||||
}
|
||||
|
||||
{{formatComment "DELETE /{{pluralize (.Entity.Name | lower)}}/:id"}}
|
||||
func (c *{{.Entity.Name | title}}Controller) Delete{{.Entity.Name | title}}(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
// TODO: Delete from database using id
|
||||
_ = id
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
104
examples/custom-js-functions/templates/manifest.yaml
Normal file
104
examples/custom-js-functions/templates/manifest.yaml
Normal file
@ -0,0 +1,104 @@
|
||||
name: "Blog Generator with Custom JS Functions"
|
||||
description: "Demonstrates custom JavaScript template functions using Otto interpreter"
|
||||
|
||||
# Custom JavaScript functions that can be used in templates
|
||||
functions:
|
||||
# Convert a string to a URL-friendly slug
|
||||
generateSlug: |
|
||||
function main() {
|
||||
var input = args[0] || "";
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
# Convert singular words to plural (simple English rules) - ES5 compatible
|
||||
pluralize: |
|
||||
function main() {
|
||||
var word = args[0] || "";
|
||||
if (word.slice(-1) === 'y') {
|
||||
return word.slice(0, -1) + 'ies';
|
||||
} else if (word.slice(-1) === 's' || word.slice(-2) === 'sh' || word.slice(-2) === 'ch' || word.slice(-1) === 'x' || word.slice(-1) === 'z') {
|
||||
return word + 'es';
|
||||
} else {
|
||||
return word + 's';
|
||||
}
|
||||
}
|
||||
|
||||
# Generate validation rules based on field type
|
||||
generateValidation: |
|
||||
function main() {
|
||||
var fieldType = args[0] || "";
|
||||
var fieldName = args[1] || "field";
|
||||
|
||||
switch (fieldType) {
|
||||
case 'string':
|
||||
return 'if len(' + fieldName + ') == 0 { return errors.New("' + fieldName + ' is required") }';
|
||||
case 'uuid':
|
||||
return 'if _, err := uuid.Parse(' + fieldName + '); err != nil { return errors.New("invalid UUID format") }';
|
||||
case 'boolean':
|
||||
return '// Boolean validation not required';
|
||||
case 'timestamp':
|
||||
return 'if ' + fieldName + '.IsZero() { return errors.New("' + fieldName + ' is required") }';
|
||||
default:
|
||||
return '// No validation rules for type: ' + fieldType;
|
||||
}
|
||||
}
|
||||
|
||||
# Format a comment block
|
||||
formatComment: |
|
||||
function main() {
|
||||
var text = args[0] || "";
|
||||
var lines = text.split('\n');
|
||||
var result = '// ' + lines.join('\n// ');
|
||||
return result;
|
||||
}
|
||||
|
||||
# Generate database field mapping
|
||||
dbFieldMapping: |
|
||||
function main() {
|
||||
var fieldName = args[0] || "";
|
||||
var fieldType = args[1] || "string";
|
||||
|
||||
var dbType;
|
||||
switch (fieldType) {
|
||||
case 'string':
|
||||
dbType = 'VARCHAR(255)';
|
||||
break;
|
||||
case 'text':
|
||||
dbType = 'TEXT';
|
||||
break;
|
||||
case 'uuid':
|
||||
dbType = 'UUID';
|
||||
break;
|
||||
case 'boolean':
|
||||
dbType = 'BOOLEAN';
|
||||
break;
|
||||
case 'timestamp':
|
||||
dbType = 'TIMESTAMP';
|
||||
break;
|
||||
default:
|
||||
dbType = 'VARCHAR(255)';
|
||||
}
|
||||
|
||||
return '`db:"' + fieldName.toLowerCase() + '" json:"' + fieldName + '"`';
|
||||
}
|
||||
|
||||
outputs:
|
||||
# Generate Go models for each entity using custom functions
|
||||
- path: "models/{{.Entity.Name | lower}}.go"
|
||||
template: "model"
|
||||
iterator: "entities"
|
||||
item_context: "Entity"
|
||||
|
||||
# Generate controllers for each entity
|
||||
- path: "controllers/{{.Entity.Name | lower}}_controller.go"
|
||||
template: "controller"
|
||||
iterator: "entities"
|
||||
item_context: "Entity"
|
||||
|
||||
# Generate a single router file
|
||||
- path: "router.go"
|
||||
template: "router"
|
||||
condition: "has_entities"
|
44
examples/custom-js-functions/templates/model.tmpl
Normal file
44
examples/custom-js-functions/templates/model.tmpl
Normal file
@ -0,0 +1,44 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
{{formatComment (printf "Model for %s entity\nGenerated with custom JavaScript functions" .Entity.Name)}}
|
||||
type {{.Entity.Name | title}} struct {
|
||||
{{- range .Entity.Fields}}
|
||||
{{.Name | title}} {{goType .Type}} {{dbFieldMapping .Name .Type}}
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
{{formatComment "Table name for GORM"}}
|
||||
func ({{.Entity.Name | title}}) TableName() string {
|
||||
return "{{pluralize (.Entity.Name | lower)}}"
|
||||
}
|
||||
|
||||
{{formatComment "Validation function using custom JavaScript validation rules"}}
|
||||
func (m *{{.Entity.Name | title}}) Validate() error {
|
||||
{{- range .Entity.Fields}}
|
||||
{{- if .Required}}
|
||||
{{generateValidation .Type .Name}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
return nil
|
||||
}
|
||||
|
||||
{{formatComment "Create a new instance with validation"}}
|
||||
func New{{.Entity.Name | title}}({{range $i, $field := .Entity.Fields}}{{if $i}}, {{end}}{{$field.Name | lower}} {{goType $field.Type}}{{end}}) (*{{.Entity.Name | title}}, error) {
|
||||
model := &{{.Entity.Name | title}}{
|
||||
{{- range .Entity.Fields}}
|
||||
{{.Name | title}}: {{.Name | lower}},
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
if err := model.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
28
examples/custom-js-functions/templates/router.tmpl
Normal file
28
examples/custom-js-functions/templates/router.tmpl
Normal file
@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"your-app/controllers"
|
||||
)
|
||||
|
||||
{{formatComment "Router setup with all entity routes\nGenerated using custom JavaScript template functions"}}
|
||||
func SetupRouter() *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
|
||||
{{range $entity := .AST.Definitions}}
|
||||
{{- if $entity.Entity}}
|
||||
{{formatComment (printf "Routes for %s entity" $entity.Entity.Name)}}
|
||||
{{$entity.Entity.Name | lower}}Controller := controllers.New{{$entity.Entity.Name | title}}Controller()
|
||||
router.HandleFunc("/{{pluralize ($entity.Entity.Name | lower)}}", {{$entity.Entity.Name | lower}}Controller.List{{pluralize ($entity.Entity.Name | title)}}).Methods("GET")
|
||||
router.HandleFunc("/{{pluralize ($entity.Entity.Name | lower)}}/:id", {{$entity.Entity.Name | lower}}Controller.Get{{$entity.Entity.Name | title}}).Methods("GET")
|
||||
router.HandleFunc("/{{pluralize ($entity.Entity.Name | lower)}}", {{$entity.Entity.Name | lower}}Controller.Create{{$entity.Entity.Name | title}}).Methods("POST")
|
||||
router.HandleFunc("/{{pluralize ($entity.Entity.Name | lower)}}/:id", {{$entity.Entity.Name | lower}}Controller.Update{{$entity.Entity.Name | title}}).Methods("PUT")
|
||||
router.HandleFunc("/{{pluralize ($entity.Entity.Name | lower)}}/:id", {{$entity.Entity.Name | lower}}Controller.Delete{{$entity.Entity.Name | title}}).Methods("DELETE")
|
||||
|
||||
{{end}}
|
||||
{{- end}}
|
||||
|
||||
return router
|
||||
}
|
@ -96,7 +96,7 @@ func main() {
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Page != nil {
|
||||
pageCount++
|
||||
totalContent := len(def.Page.Meta) + len(def.Page.Sections) + len(def.Page.Components)
|
||||
totalContent := len(def.Page.Meta) + len(def.Page.Elements)
|
||||
if totalContent > 0 {
|
||||
fmt.Printf(" ✓ Page '%s' has %d content items (block syntax working)\n", def.Page.Name, totalContent)
|
||||
}
|
||||
@ -110,9 +110,11 @@ func main() {
|
||||
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)
|
||||
for _, element := range def.Page.Elements {
|
||||
if element.Section != nil {
|
||||
totalSections++
|
||||
nestedSections += countNestedSections(*element.Section)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
// 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 {
|
||||
|
202
examples/lang/readme.md
Normal file
202
examples/lang/readme.md
Normal file
@ -0,0 +1,202 @@
|
||||
# Basic DSL Usage
|
||||
|
||||
## Overview
|
||||
|
||||
The Masonry DSL (Domain Specific Language) is a unified syntax for defining full-stack applications. It allows you to describe servers, entities, API endpoints, and user interfaces in a single file, which can then be generated into various output formats including Go servers, HTML, and more.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Create a new Masonry file** (e.g., `app.masonry`)
|
||||
2. **Define your application structure** using the DSL syntax
|
||||
3. **Generate code** using the Masonry CLI
|
||||
|
||||
## Basic DSL Structure
|
||||
|
||||
A Masonry file consists of these main components:
|
||||
|
||||
### 1. Server Configuration
|
||||
```masonry
|
||||
server MyApp {
|
||||
host "localhost"
|
||||
port 8080
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Entity Definitions
|
||||
Define your data models with fields, types, validation, and relationships:
|
||||
|
||||
```masonry
|
||||
entity User desc "User account management" {
|
||||
id: uuid required unique
|
||||
email: string required validate email
|
||||
name: string default "Anonymous"
|
||||
created_at: timestamp default "now()"
|
||||
profile_id: uuid relates to Profile as one
|
||||
}
|
||||
```
|
||||
|
||||
**Supported field types:**
|
||||
- `uuid`, `string`, `text`, `int`, `boolean`, `timestamp`
|
||||
|
||||
**Field modifiers:**
|
||||
- `required` - Field is mandatory
|
||||
- `unique` - Field must be unique
|
||||
- `indexed` - Field should be indexed
|
||||
- `default "value"` - Set default value
|
||||
- `validate email` - Email validation
|
||||
- `validate url` - URL validation
|
||||
- `validate min_length "5"` - Minimum length validation
|
||||
- `validate max_length "100"` - Maximum length validation
|
||||
|
||||
**Relationships:**
|
||||
- `relates to EntityName as one` - One-to-one relationship
|
||||
- `relates to EntityName as many` - One-to-many relationship
|
||||
- `relates to EntityName as many through "table_name"` - Many-to-many through table
|
||||
|
||||
### 3. API Endpoints
|
||||
Define REST API endpoints with parameters and responses:
|
||||
|
||||
```masonry
|
||||
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]
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP methods:** `GET`, `POST`, `PUT`, `DELETE`
|
||||
|
||||
**Parameter sources:**
|
||||
- `from query` - Query string parameter
|
||||
- `from path` - URL path parameter
|
||||
- `from body` - Request body parameter
|
||||
|
||||
**Features:**
|
||||
- `auth` - Requires authentication
|
||||
- `returns list/object` - Response type
|
||||
- `fields [...]` - Specify response fields
|
||||
|
||||
### 4. User Interface Pages
|
||||
Define web pages with components and layouts:
|
||||
|
||||
```masonry
|
||||
page UserManagement at "/admin/users" layout AdminLayout title "User Management" auth {
|
||||
meta description "Manage system users"
|
||||
|
||||
section main type container {
|
||||
component UserTable for User {
|
||||
fields [email, name, created_at]
|
||||
actions [edit, delete, view]
|
||||
data from "/users"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Page features:**
|
||||
- `layout LayoutName` - Page layout
|
||||
- `title "Page Title"` - Page title
|
||||
- `auth` - Requires authentication
|
||||
- `meta` - SEO metadata
|
||||
|
||||
**Sections and Components:**
|
||||
- `section` - Layout containers with types (container, panel, tab, modal)
|
||||
- `component` - Reusable UI elements
|
||||
- `data from "/endpoint"` - Data binding
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Generate a Server
|
||||
Generate and optionally run a Go HTTP server:
|
||||
|
||||
```bash
|
||||
# Generate server code
|
||||
./masonry.exe serve -f app.masonry -o server.go
|
||||
|
||||
# Generate and run server
|
||||
./masonry.exe serve -f app.masonry -r
|
||||
```
|
||||
|
||||
### Generate HTML
|
||||
Convert Masonry files to static HTML:
|
||||
|
||||
```bash
|
||||
./masonry.exe generate html -i app.masonry -o ./output
|
||||
```
|
||||
|
||||
### Template-based Generation
|
||||
Use custom templates for code generation:
|
||||
|
||||
```bash
|
||||
# Use a single template file
|
||||
./masonry.exe template -i app.masonry -t my-template.tmpl -o output.go
|
||||
|
||||
# Use a template directory
|
||||
./masonry.exe template -i app.masonry -d ./templates -r main.tmpl -o output.go
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. **Create a simple blog application:**
|
||||
|
||||
```masonry
|
||||
server BlogApp {
|
||||
host "localhost"
|
||||
port 8080
|
||||
}
|
||||
|
||||
entity Post {
|
||||
id: uuid required unique
|
||||
title: string required
|
||||
content: text required
|
||||
published: boolean default "false"
|
||||
created_at: timestamp default "now()"
|
||||
}
|
||||
|
||||
endpoint GET "/posts" for Post {
|
||||
returns list fields [id, title, published]
|
||||
}
|
||||
|
||||
endpoint POST "/posts" for Post {
|
||||
param post_data: object required from body
|
||||
returns object
|
||||
}
|
||||
|
||||
page PostList at "/posts" title "Blog Posts" {
|
||||
component PostTable for Post {
|
||||
fields [title, published, created_at]
|
||||
data from "/posts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Generate and run the server:**
|
||||
|
||||
```bash
|
||||
./masonry.exe serve -f blog.masonry -r
|
||||
```
|
||||
|
||||
3. **Visit http://localhost:8080 to see your generated application**
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- **Conditional rendering** with `when` statements
|
||||
- **Complex layouts** with nested sections
|
||||
- **Form validation** and custom field types
|
||||
- **Authentication** and authorization
|
||||
- **Relationships** between entities
|
||||
- **Custom templates** for different output formats
|
||||
|
||||
For a complete example showcasing all features, see the `example.masonry` file in this directory.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Explore the full `example.masonry` to see advanced features
|
||||
- Create custom templates for your preferred frameworks
|
||||
- Use the `setup` command to install required dependencies
|
||||
- Check out other examples in the `/examples` directory
|
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
92
examples/react-app-generator/blog-app.masonry
Normal file
92
examples/react-app-generator/blog-app.masonry
Normal file
@ -0,0 +1,92 @@
|
||||
// Example blog application with multiple pages, entities, and components
|
||||
server api {
|
||||
host "localhost"
|
||||
port 3001
|
||||
}
|
||||
|
||||
entity User {
|
||||
name: string required
|
||||
email: string required unique
|
||||
role: string default "user"
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
entity Post {
|
||||
title: string required
|
||||
content: text required
|
||||
published: boolean default "false"
|
||||
author_id: string required
|
||||
created_at: timestamp
|
||||
updated_at: timestamp
|
||||
}
|
||||
|
||||
endpoint GET "/api/users" for User auth {
|
||||
returns User as "json"
|
||||
}
|
||||
|
||||
endpoint POST "/api/users" for User {
|
||||
param name: string required from body
|
||||
param email: string required from body
|
||||
returns User as "json"
|
||||
}
|
||||
|
||||
endpoint GET "/api/posts" for Post {
|
||||
returns Post as "json"
|
||||
}
|
||||
|
||||
endpoint POST "/api/posts" for Post auth {
|
||||
param title: string required from body
|
||||
param content: string required from body
|
||||
returns Post as "json"
|
||||
}
|
||||
|
||||
page Home at "/" layout public title "My Blog" desc "A simple blog built with Masonry" {
|
||||
meta description "A simple blog built with Masonry"
|
||||
|
||||
section hero type container {
|
||||
component banner
|
||||
}
|
||||
|
||||
section content type container {
|
||||
component list for Post {
|
||||
fields [title, content, created_at]
|
||||
}
|
||||
|
||||
section sidebar type panel {
|
||||
component widget {
|
||||
data from "/api/recent-posts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component footer
|
||||
}
|
||||
|
||||
page Dashboard at "/admin/dashboard" layout admin title "Admin Dashboard" auth {
|
||||
section stats type container {
|
||||
component table for User {
|
||||
fields [name, email, role, created_at]
|
||||
actions [edit, delete]
|
||||
}
|
||||
}
|
||||
|
||||
section management type container {
|
||||
component form for Post {
|
||||
fields [title, content, published]
|
||||
validate
|
||||
}
|
||||
|
||||
component table for Post {
|
||||
fields [title, published, created_at]
|
||||
actions [edit, delete, publish]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page About at "/about" layout public title "About Us" {
|
||||
section info type container {
|
||||
component text {
|
||||
data from "/api/about-content"
|
||||
}
|
||||
}
|
||||
}
|
57
examples/react-app-generator/readme.md
Normal file
57
examples/react-app-generator/readme.md
Normal file
@ -0,0 +1,57 @@
|
||||
# React App Generator Example
|
||||
|
||||
This example demonstrates how to use Masonry's multi-file template generation to create a complete React application with components, pages, and API integration.
|
||||
|
||||
## What This Example Does
|
||||
|
||||
This template generator creates:
|
||||
- **React Pages** - One file per page definition (public and admin layouts)
|
||||
- **React Components** - Individual component files for each component/section
|
||||
- **API Client** - Generated API functions for each entity
|
||||
- **Type Definitions** - TypeScript interfaces for entities
|
||||
- **Routing** - React Router setup based on page definitions
|
||||
|
||||
## Files Generated
|
||||
|
||||
The manifest defines outputs that generate:
|
||||
- `src/pages/` - Page components based on layout type
|
||||
- `src/components/` - Individual component files
|
||||
- `src/api/` - API client functions per entity
|
||||
- `src/types/` - TypeScript interfaces per entity
|
||||
- `src/router.tsx` - React Router configuration
|
||||
- `package.json` - Project dependencies
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Create a Masonry file** (see `blog-app.masonry` example)
|
||||
2. **Run the generator**:
|
||||
```bash
|
||||
../../masonry.exe generate-multi \
|
||||
--input blog-app.masonry \
|
||||
--template-dir templates \
|
||||
--manifest manifest.yaml \
|
||||
--output-dir ./generated-app
|
||||
```
|
||||
3. **Install dependencies and run**:
|
||||
```bash
|
||||
cd generated-app
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
## Template Features Demonstrated
|
||||
|
||||
- **Per-item iteration** - Generate one file per page, entity, component
|
||||
- **Conditional generation** - Different templates for admin vs public layouts
|
||||
- **Dynamic paths** - File paths based on item properties
|
||||
- **Nested template composition** - Components using other templates
|
||||
- **Template functions** - String manipulation, type conversion
|
||||
|
||||
## Extending This Example
|
||||
|
||||
You can extend this template by:
|
||||
- Adding more layout types (mobile, desktop, etc.)
|
||||
- Creating specialized component templates (forms, tables, etc.)
|
||||
- Adding test file generation
|
||||
- Including CSS module generation
|
||||
- Adding Docker/deployment configurations
|
54
examples/react-app-generator/templates/api-base.tmpl
Normal file
54
examples/react-app-generator/templates/api-base.tmpl
Normal file
@ -0,0 +1,54 @@
|
||||
// Base API client configuration
|
||||
{{range .AST.Definitions}}{{if .Server}}const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://{{.Server.Settings | getHost}}:{{.Server.Settings | getPort}}';
|
||||
{{end}}{{end}}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
data?: any
|
||||
): Promise<T> {
|
||||
const config: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
if (data) {
|
||||
config.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`HTTP error! status: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ApiError(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`, 0);
|
||||
}
|
||||
}
|
33
examples/react-app-generator/templates/api-client.tmpl
Normal file
33
examples/react-app-generator/templates/api-client.tmpl
Normal file
@ -0,0 +1,33 @@
|
||||
// API client for {{.Entity.Name}}
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export interface {{.Entity.Name}} {
|
||||
{{range .Entity.Fields}} {{.Name}}: {{if eq .Type "string"}}string{{else if eq .Type "int"}}number{{else if eq .Type "boolean"}}boolean{{else if eq .Type "timestamp"}}Date{{else if eq .Type "text"}}string{{else}}any{{end}};
|
||||
{{end}}}
|
||||
|
||||
export const {{.Entity.Name | lower}}Api = {
|
||||
// Get all {{.Entity.Name | lower}}s
|
||||
getAll: async (): Promise<{{.Entity.Name}}[]> => {
|
||||
return apiRequest('GET', '/{{.Entity.Name | lower}}s');
|
||||
},
|
||||
|
||||
// Get {{.Entity.Name | lower}} by ID
|
||||
getById: async (id: string): Promise<{{.Entity.Name}}> => {
|
||||
return apiRequest('GET', `/{{.Entity.Name | lower}}s/${id}`);
|
||||
},
|
||||
|
||||
// Create new {{.Entity.Name | lower}}
|
||||
create: async (data: Omit<{{.Entity.Name}}, 'id' | 'created_at' | 'updated_at'>): Promise<{{.Entity.Name}}> => {
|
||||
return apiRequest('POST', '/{{.Entity.Name | lower}}s', data);
|
||||
},
|
||||
|
||||
// Update {{.Entity.Name | lower}}
|
||||
update: async (id: string, data: Partial<{{.Entity.Name}}>): Promise<{{.Entity.Name}}> => {
|
||||
return apiRequest('PUT', `/{{.Entity.Name | lower}}s/${id}`, data);
|
||||
},
|
||||
|
||||
// Delete {{.Entity.Name | lower}}
|
||||
delete: async (id: string): Promise<void> => {
|
||||
return apiRequest('DELETE', `/{{.Entity.Name | lower}}s/${id}`);
|
||||
},
|
||||
};
|
13
examples/react-app-generator/templates/app-component.tmpl
Normal file
13
examples/react-app-generator/templates/app-component.tmpl
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import AppRouter from './router';
|
||||
// import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<AppRouter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
46
examples/react-app-generator/templates/index-html.tmpl
Normal file
46
examples/react-app-generator/templates/index-html.tmpl
Normal file
@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="{{range .AST.Definitions}}{{if .Page}}{{if .Page.Description}}{{.Page.Description | derefString}}{{else}}Web site created using Masonry{{end}}{{break}}{{end}}{{end}}"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>{{range .AST.Definitions}}{{if .Page}}{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}{{break}}{{else}}Masonry App{{end}}{{end}}</title>
|
||||
|
||||
<!-- Tailwind CSS for styling -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
14
examples/react-app-generator/templates/index.tmpl
Normal file
14
examples/react-app-generator/templates/index.tmpl
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
// import './index.css';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
67
examples/react-app-generator/templates/manifest.yaml
Normal file
67
examples/react-app-generator/templates/manifest.yaml
Normal file
@ -0,0 +1,67 @@
|
||||
name: "React App Generator"
|
||||
description: "Generates a complete React application with pages, components, and API integration"
|
||||
outputs:
|
||||
# Generate public pages
|
||||
- path: "src/pages/{{.Page.Name}}.tsx"
|
||||
template: "page-public"
|
||||
iterator: "pages"
|
||||
item_context: "Page"
|
||||
condition: "layout_public"
|
||||
|
||||
# Generate admin pages
|
||||
- path: "src/pages/admin/{{.Page.Name}}.tsx"
|
||||
template: "page-admin"
|
||||
iterator: "pages"
|
||||
item_context: "Page"
|
||||
condition: "layout_admin"
|
||||
|
||||
# Generate component files for each section
|
||||
- path: "src/components/sections/{{.Section.Name | title}}Section.tsx"
|
||||
template: "section-component"
|
||||
iterator: "sections"
|
||||
item_context: "Section"
|
||||
|
||||
# Generate component files for each component
|
||||
- path: "src/components/{{.Component.Type | title}}Component.tsx"
|
||||
template: "react-component"
|
||||
iterator: "components"
|
||||
item_context: "Component"
|
||||
|
||||
# Generate API clients per entity
|
||||
- path: "src/api/{{.Entity.Name | lower}}.ts"
|
||||
template: "api-client"
|
||||
iterator: "entities"
|
||||
item_context: "Entity"
|
||||
|
||||
# Generate TypeScript types per entity
|
||||
- path: "src/types/{{.Entity.Name}}.ts"
|
||||
template: "typescript-types"
|
||||
iterator: "entities"
|
||||
item_context: "Entity"
|
||||
|
||||
# Single files - global configuration
|
||||
- path: "src/router.tsx"
|
||||
template: "react-router"
|
||||
condition: "has_pages"
|
||||
|
||||
- path: "src/api/client.ts"
|
||||
template: "api-base"
|
||||
condition: "has_entities"
|
||||
|
||||
- path: "package.json"
|
||||
template: "package-json"
|
||||
|
||||
- path: "src/App.tsx"
|
||||
template: "app-component"
|
||||
condition: "has_pages"
|
||||
|
||||
- path: "src/index.tsx"
|
||||
template: "index"
|
||||
|
||||
# Add the missing index.html file
|
||||
- path: "public/index.html"
|
||||
template: "index-html"
|
||||
|
||||
# Add TypeScript configuration file
|
||||
- path: "tsconfig.json"
|
||||
template: "tsconfig"
|
45
examples/react-app-generator/templates/package-json.tmpl
Normal file
45
examples/react-app-generator/templates/package-json.tmpl
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "masonry-generated-blog-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.13.0",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.21"
|
||||
}
|
||||
}
|
||||
|
68
examples/react-app-generator/templates/page-admin.tmpl
Normal file
68
examples/react-app-generator/templates/page-admin.tmpl
Normal file
@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
{{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}';
|
||||
{{end}}{{end}}
|
||||
{{range .Page.Elements}}{{if .Section}}import {{.Section.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Section.Name | title}}Section';
|
||||
{{end}}{{if .Component}}import {{.Component.Type | title}}Component from '{{$relativePrefix}}components/{{.Component.Type | title}}Component';
|
||||
{{end}}{{end}}
|
||||
|
||||
export default function {{.Page.Name}}Page() {
|
||||
const navigate = useNavigate();
|
||||
const [user] = useState(null); // TODO: Implement actual auth
|
||||
|
||||
// Redirect if not authenticated
|
||||
React.useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
if (!user) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link to="/admin/dashboard" className="text-xl font-bold text-blue-600">
|
||||
Admin Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex space-x-8">
|
||||
{{range .AST.Definitions}}{{if .Page}}{{if eq .Page.Layout "admin"}}<Link
|
||||
to="{{.Page.Path}}"
|
||||
className="text-gray-500 hover:text-gray-700 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
{{.Page.Name}}
|
||||
</Link>
|
||||
{{end}}{{end}}{{end}}
|
||||
<button className="text-red-600 hover:text-red-700 px-3 py-2 rounded-md text-sm font-medium">
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||||
{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}
|
||||
</h1>
|
||||
|
||||
{{range .Page.Elements}}
|
||||
{{if .Section}}
|
||||
<{{.Section.Name | title}}Section />
|
||||
{{end}}
|
||||
{{if .Component}}
|
||||
<{{.Component.Type | title}}Component />
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
51
examples/react-app-generator/templates/page-public.tmpl
Normal file
51
examples/react-app-generator/templates/page-public.tmpl
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
{{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}';
|
||||
{{end}}{{end}}
|
||||
{{range .Page.Elements}}{{if .Section}}import {{.Section.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Section.Name | title}}Section';
|
||||
{{end}}{{if .Component}}import {{.Component.Type | title}}Component from '{{$relativePrefix}}components/{{.Component.Type | title}}Component';
|
||||
{{end}}{{end}}
|
||||
|
||||
export default function {{.Page.Name}}Page() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link to="/" className="text-xl font-bold text-gray-900">
|
||||
My Blog
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex space-x-8">
|
||||
{{range .AST.Definitions}}{{if .Page}}{{if ne .Page.Layout "admin"}}<Link
|
||||
to="{{.Page.Path}}"
|
||||
className="text-gray-500 hover:text-gray-700 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
{{.Page.Name}}
|
||||
</Link>
|
||||
{{end}}{{end}}{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||||
{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}
|
||||
</h1>
|
||||
|
||||
{{range .Page.Elements}}
|
||||
{{if .Section}}
|
||||
<{{.Section.Name | title}}Section />
|
||||
{{end}}
|
||||
{{if .Component}}
|
||||
<{{.Component.Type | title}}Component />
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
113
examples/react-app-generator/templates/react-component.tmpl
Normal file
113
examples/react-app-generator/templates/react-component.tmpl
Normal file
@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
{{if .Component.Entity}}import { {{.Component.Entity}} } from '../types/{{.Component.Entity}}';
|
||||
{{end}}
|
||||
|
||||
interface {{.Component.Type | title}}ComponentProps {
|
||||
className?: string;
|
||||
{{if .Component.Entity}}data?: {{.Component.Entity}}[];
|
||||
{{end}}
|
||||
}
|
||||
|
||||
export default function {{.Component.Type | title}}Component({
|
||||
className = '',
|
||||
{{if .Component.Entity}}data{{end}}
|
||||
}: {{.Component.Type | title}}ComponentProps) {
|
||||
{{if eq .Component.Type "form"}}
|
||||
const [formData, setFormData] = React.useState({});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log('Form submitted:', formData);
|
||||
// TODO: Implement form submission
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white p-6 rounded-lg shadow ${className}`}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{{range .Component.Elements}}{{if .Field}}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{{.Field.Name | title}}
|
||||
</label>
|
||||
<input
|
||||
type="{{if eq .Field.Type "text"}}text{{else if eq .Field.Type "email"}}email{{else}}text{{end}}"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => setFormData({...formData, {{.Field.Name}}: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
{{else if eq .Component.Type "table"}}
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow overflow-hidden ${className}`}>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{{range .Component.Elements}}{{if .Field}}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{.Field.Name | title}}
|
||||
</th>
|
||||
{{end}}{{end}}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data?.map((item, index) => (
|
||||
<tr key={index}>
|
||||
{{range .Component.Elements}}{{if .Field}}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.{{.Field.Name}}}
|
||||
</td>
|
||||
{{end}}{{end}}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button className="text-blue-600 hover:text-blue-900 mr-2">
|
||||
Edit
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
{{else if eq .Component.Type "list"}}
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{data?.map((item, index) => (
|
||||
<div key={index} className="bg-white p-6 rounded-lg shadow">
|
||||
{{range .Component.Elements}}{{if .Field}}
|
||||
<div className="mb-2">
|
||||
<span className="font-semibold text-gray-700">{{.Field.Name | title}}:</span>
|
||||
<span className="ml-2 text-gray-900">{item.{{.Field.Name}}}</span>
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
{{else}}
|
||||
return (
|
||||
<div className={`bg-white p-6 rounded-lg shadow ${className}`}>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{{.Component.Type | title}} Component
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
This is a {{.Component.Type}} component. Add your custom implementation here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
{{end}}
|
||||
}
|
19
examples/react-app-generator/templates/react-router.tmpl
Normal file
19
examples/react-app-generator/templates/react-router.tmpl
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
{{range .AST.Definitions}}{{if .Page}}import {{.Page.Name}}Page from './pages/{{if eq .Page.Layout "admin"}}admin/{{end}}{{.Page.Name}}';
|
||||
{{end}}{{end}}
|
||||
|
||||
export default function AppRouter() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
{{range .AST.Definitions}}{{if .Page}}
|
||||
<Route
|
||||
path="{{.Page.Path}}"
|
||||
element={<{{.Page.Name}}Page />}
|
||||
/>
|
||||
{{end}}{{end}}
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
{{if .Section.Entity}}import { {{.Section.Entity}} } from '../../types/{{.Section.Entity}}';
|
||||
{{end}}{{range .Section.Elements}}{{if .Section}}import {{.Section.Name | title}}Section from '../sections/{{.Section.Name | title}}Section';{{end}}{{if .Component}}import {{.Component.Type | title}}Component from '../{{.Component.Type | title}}Component';{{end}}{{end}}
|
||||
|
||||
interface {{.Section.Name | title}}SectionProps {
|
||||
className?: string;
|
||||
{{if .Section.Entity}}data?: {{.Section.Entity}}[];
|
||||
{{end}}
|
||||
}
|
||||
|
||||
export default function {{.Section.Name | title}}Section({
|
||||
className = ''
|
||||
{{if .Section.Entity}}, data{{end}}
|
||||
}: {{.Section.Name | title}}SectionProps) {
|
||||
return (
|
||||
<section className={`{{if .Section.Type}}{{.Section.Type}}{{else}}container{{end}} ${className}`}>
|
||||
{{if .Section.Label}}<h2 className="text-2xl font-bold mb-4">{{.Section.Label | derefString}}</h2>
|
||||
{{end}}
|
||||
|
||||
<div className="space-y-6">
|
||||
{{range .Section.Elements}}
|
||||
{{if .Component}}
|
||||
<{{.Component.Type | title}}Component />
|
||||
{{end}}
|
||||
{{if .Section}}
|
||||
<{{.Section.Name | title}}Section />
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
27
examples/react-app-generator/templates/tsconfig.tmpl
Normal file
27
examples/react-app-generator/templates/tsconfig.tmpl
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es6"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
export interface {{.Entity.Name}} {
|
||||
{{range .Entity.Fields}} {{.Name}}: {{if eq .Type "string"}}string{{else if eq .Type "int"}}number{{else if eq .Type "boolean"}}boolean{{else if eq .Type "timestamp"}}Date{{else if eq .Type "text"}}string{{else}}any{{end}}{{if .Required}} // Required{{end}}{{if .Default}} // Default: {{.Default | derefString}}{{end}};
|
||||
{{end}}}
|
||||
|
||||
{{if .Entity.Fields}}
|
||||
// Additional validation and utility types can be added here
|
||||
{{end}}
|
12
examples/react-app-generator/test-simple.masonry
Normal file
12
examples/react-app-generator/test-simple.masonry
Normal file
@ -0,0 +1,12 @@
|
||||
// Simple test to verify parsing
|
||||
server api {
|
||||
host "localhost"
|
||||
port 3001
|
||||
}
|
||||
|
||||
entity User {
|
||||
name: string required
|
||||
email: string required
|
||||
}
|
||||
|
||||
page Home at "/" layout public
|
3
go.mod
3
go.mod
@ -4,12 +4,15 @@ go 1.23
|
||||
|
||||
require (
|
||||
github.com/alecthomas/participle/v2 v2.1.4
|
||||
github.com/robertkrimen/otto v0.5.1
|
||||
github.com/urfave/cli/v2 v2.27.5
|
||||
golang.org/x/text v0.22.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
gopkg.in/sourcemap.v1 v1.0.5 // indirect
|
||||
)
|
||||
|
14
go.sum
14
go.sum
@ -6,13 +6,27 @@ 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/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
|
||||
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
|
||||
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/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
|
||||
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
455
interpreter/custom_js_functions_test.go
Normal file
455
interpreter/custom_js_functions_test.go
Normal file
@ -0,0 +1,455 @@
|
||||
package interpreter
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomJavaScriptFunctions(t *testing.T) {
|
||||
// Create a temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a test Masonry file
|
||||
masonryContent := `entity User {
|
||||
name: string required
|
||||
email: string required unique
|
||||
}
|
||||
|
||||
entity Post {
|
||||
title: string required
|
||||
slug: string required
|
||||
authorId: uuid required
|
||||
}`
|
||||
|
||||
masonryFile := filepath.Join(tempDir, "test.masonry")
|
||||
err := os.WriteFile(masonryFile, []byte(masonryContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write test Masonry file: %v", err)
|
||||
}
|
||||
|
||||
// Create templates directory
|
||||
templatesDir := filepath.Join(tempDir, "templates")
|
||||
err = os.MkdirAll(templatesDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create templates directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a test template that uses custom functions
|
||||
templateContent := `package models
|
||||
|
||||
// {{formatComment "Generated model"}}
|
||||
type {{.Entity.Name | title}} struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// Table name: {{pluralize (.Entity.Name | lower)}}
|
||||
func ({{.Entity.Name | title}}) TableName() string {
|
||||
return "{{pluralize (.Entity.Name | lower)}}"
|
||||
}
|
||||
|
||||
// Custom function result: {{customUpper (.Entity.Name)}}`
|
||||
|
||||
templateFile := filepath.Join(templatesDir, "model.tmpl")
|
||||
err = os.WriteFile(templateFile, []byte(templateContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write test template file: %v", err)
|
||||
}
|
||||
|
||||
// Create manifest with custom JavaScript functions
|
||||
manifestContent := `name: "Test Generator"
|
||||
description: "Test custom JavaScript functions"
|
||||
|
||||
functions:
|
||||
# Format a comment block
|
||||
formatComment: |
|
||||
function main() {
|
||||
var text = args[0] || "";
|
||||
return "// " + text;
|
||||
}
|
||||
|
||||
# Convert singular words to plural (simple rules)
|
||||
pluralize: |
|
||||
function main() {
|
||||
var word = args[0] || "";
|
||||
if (word.slice(-1) === 'y') {
|
||||
return word.slice(0, -1) + 'ies';
|
||||
} else if (word.slice(-1) === 's') {
|
||||
return word + 'es';
|
||||
} else {
|
||||
return word + 's';
|
||||
}
|
||||
}
|
||||
|
||||
# Convert to uppercase
|
||||
customUpper: |
|
||||
function main() {
|
||||
var text = args[0] || "";
|
||||
return text.toUpperCase();
|
||||
}
|
||||
|
||||
outputs:
|
||||
- path: "models/{{.Entity.Name | lower}}.go"
|
||||
template: "model"
|
||||
iterator: "entities"
|
||||
item_context: "Entity"`
|
||||
|
||||
manifestFile := filepath.Join(templatesDir, "manifest.yaml")
|
||||
err = os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write test manifest file: %v", err)
|
||||
}
|
||||
|
||||
// Create template interpreter and test
|
||||
interpreter := NewTemplateInterpreter()
|
||||
|
||||
// Test InterpretToFiles with custom functions
|
||||
outputs, err := interpreter.InterpretToFiles(masonryFile, templatesDir, "manifest.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("InterpretToFiles failed: %v", err)
|
||||
}
|
||||
|
||||
// Should generate 2 files (User and Post)
|
||||
expectedFiles := 2
|
||||
if len(outputs) != expectedFiles {
|
||||
t.Errorf("Expected %d output files, got %d", expectedFiles, len(outputs))
|
||||
}
|
||||
|
||||
// Check User model output
|
||||
userFile := "models/user.go"
|
||||
userContent, exists := outputs[userFile]
|
||||
if !exists {
|
||||
t.Errorf("Expected output file %s not found", userFile)
|
||||
} else {
|
||||
// Test formatComment function
|
||||
if !strings.Contains(userContent, "// Generated model") {
|
||||
t.Errorf("formatComment function not working: expected '// Generated model' in output")
|
||||
}
|
||||
|
||||
// Test pluralize function
|
||||
if !strings.Contains(userContent, "Table name: users") {
|
||||
t.Errorf("pluralize function not working: expected 'Table name: users' in output")
|
||||
}
|
||||
if !strings.Contains(userContent, `return "users"`) {
|
||||
t.Errorf("pluralize function not working in template: expected 'return \"users\"' in output")
|
||||
}
|
||||
|
||||
// Test customUpper function
|
||||
if !strings.Contains(userContent, "Custom function result: USER") {
|
||||
t.Errorf("customUpper function not working: expected 'Custom function result: USER' in output")
|
||||
}
|
||||
}
|
||||
|
||||
// Check Post model output
|
||||
postFile := "models/post.go"
|
||||
postContent, exists := outputs[postFile]
|
||||
if !exists {
|
||||
t.Errorf("Expected output file %s not found", postFile)
|
||||
} else {
|
||||
// Test pluralize function with 's' ending
|
||||
if !strings.Contains(postContent, "Table name: posts") {
|
||||
t.Errorf("pluralize function not working for 's' ending: expected 'Table name: posts' in output")
|
||||
}
|
||||
if !strings.Contains(postContent, `return "posts"`) {
|
||||
t.Errorf("pluralize function not working in template for 's' ending: expected 'return \"posts\"' in output")
|
||||
}
|
||||
|
||||
// Test customUpper function
|
||||
if !strings.Contains(postContent, "Custom function result: POST") {
|
||||
t.Errorf("customUpper function not working: expected 'Custom function result: POST' in output")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomFunctionErrorHandling(t *testing.T) {
|
||||
interpreter := NewTemplateInterpreter()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
functionName string
|
||||
jsCode string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "Invalid function name",
|
||||
functionName: "123invalid",
|
||||
jsCode: "function main() { return 'test'; }",
|
||||
expectError: true,
|
||||
errorContains: "invalid function name",
|
||||
},
|
||||
{
|
||||
name: "Reserved function name",
|
||||
functionName: "and",
|
||||
jsCode: "function main() { return 'test'; }",
|
||||
expectError: true,
|
||||
errorContains: "invalid function name",
|
||||
},
|
||||
{
|
||||
name: "Missing main function",
|
||||
functionName: "testFunc",
|
||||
jsCode: "function notMain() { return 'test'; }",
|
||||
expectError: false, // Error will occur during execution, not loading
|
||||
},
|
||||
{
|
||||
name: "Valid function",
|
||||
functionName: "validFunc",
|
||||
jsCode: "function main() { return 'test'; }",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
functions := map[string]string{
|
||||
tt.functionName: tt.jsCode,
|
||||
}
|
||||
|
||||
err := interpreter.loadCustomFunctions(functions)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
} else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
|
||||
t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJavaScriptFunctionExecution(t *testing.T) {
|
||||
interpreter := NewTemplateInterpreter()
|
||||
|
||||
// Load a test function
|
||||
functions := map[string]string{
|
||||
"testConcat": `
|
||||
function main() {
|
||||
var result = "";
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
result += args[i];
|
||||
if (i < args.length - 1) result += "-";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
`,
|
||||
"testMath": `
|
||||
function main() {
|
||||
var a = args[0] || 0;
|
||||
var b = args[1] || 0;
|
||||
return a + b;
|
||||
}
|
||||
`,
|
||||
"testError": `
|
||||
function main() {
|
||||
throw new Error("Test error");
|
||||
}
|
||||
`,
|
||||
"testNoMain": `
|
||||
function notMain() {
|
||||
return "should not work";
|
||||
}
|
||||
`,
|
||||
}
|
||||
|
||||
err := interpreter.loadCustomFunctions(functions)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load custom functions: %v", err)
|
||||
}
|
||||
|
||||
// Test function execution through template
|
||||
testCases := []struct {
|
||||
name string
|
||||
template string
|
||||
data interface{}
|
||||
expectedOutput string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "Concat function with multiple args",
|
||||
template: `{{testConcat "hello" "world" "test"}}`,
|
||||
data: struct{}{},
|
||||
expectedOutput: "hello-world-test",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Math function",
|
||||
template: `{{testMath 5 3}}`,
|
||||
data: struct{}{},
|
||||
expectedOutput: "8",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Function with error",
|
||||
template: `{{testError}}`,
|
||||
data: struct{}{},
|
||||
expectError: true,
|
||||
errorContains: "Test error",
|
||||
},
|
||||
{
|
||||
name: "Function without main",
|
||||
template: `{{testNoMain}}`,
|
||||
data: struct{}{},
|
||||
expectError: true,
|
||||
errorContains: "must define a main() function",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := interpreter.Interpret("", tc.template)
|
||||
|
||||
if tc.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
} else if tc.errorContains != "" && !strings.Contains(err.Error(), tc.errorContains) {
|
||||
t.Errorf("Expected error to contain '%s', got: %v", tc.errorContains, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
} else if result != tc.expectedOutput {
|
||||
t.Errorf("Expected output '%s', got '%s'", tc.expectedOutput, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomFunctionArgumentHandling(t *testing.T) {
|
||||
interpreter := NewTemplateInterpreter()
|
||||
|
||||
// Load test functions that handle different argument types
|
||||
functions := map[string]string{
|
||||
"argCount": `
|
||||
function main() {
|
||||
return args.length;
|
||||
}
|
||||
`,
|
||||
"argTypes": `
|
||||
function main() {
|
||||
var types = [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
types.push(typeof args[i]);
|
||||
}
|
||||
return types.join(",");
|
||||
}
|
||||
`,
|
||||
"argAccess": `
|
||||
function main() {
|
||||
// Test both args array and individual arg variables
|
||||
var fromArray = args[0] || "empty";
|
||||
var fromVar = arg0 || "empty";
|
||||
return fromArray + ":" + fromVar;
|
||||
}
|
||||
`,
|
||||
}
|
||||
|
||||
err := interpreter.loadCustomFunctions(functions)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load custom functions: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
template string
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "No arguments",
|
||||
template: `{{argCount}}`,
|
||||
expectedOutput: "0",
|
||||
},
|
||||
{
|
||||
name: "Multiple arguments",
|
||||
template: `{{argCount "a" "b" "c"}}`,
|
||||
expectedOutput: "3",
|
||||
},
|
||||
{
|
||||
name: "Argument types",
|
||||
template: `{{argTypes "string" 42}}`,
|
||||
expectedOutput: "string,number",
|
||||
},
|
||||
{
|
||||
name: "Argument access methods",
|
||||
template: `{{argAccess "test"}}`,
|
||||
expectedOutput: "test:test",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := interpreter.Interpret("", tc.template)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
} else if result != tc.expectedOutput {
|
||||
t.Errorf("Expected output '%s', got '%s'", tc.expectedOutput, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionLoadingOrder(t *testing.T) {
|
||||
// This test ensures that custom functions are loaded before templates
|
||||
// to prevent the "function not defined" error that was fixed
|
||||
|
||||
tempDir := t.TempDir()
|
||||
templatesDir := filepath.Join(tempDir, "templates")
|
||||
err := os.MkdirAll(templatesDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create templates directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a template that uses a custom function
|
||||
templateContent := `{{customFunc "test"}}`
|
||||
templateFile := filepath.Join(templatesDir, "test.tmpl")
|
||||
err = os.WriteFile(templateFile, []byte(templateContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write template file: %v", err)
|
||||
}
|
||||
|
||||
// Create manifest with the custom function
|
||||
manifestContent := `name: "Order Test"
|
||||
functions:
|
||||
customFunc: |
|
||||
function main() {
|
||||
return "custom:" + args[0];
|
||||
}
|
||||
outputs:
|
||||
- path: "test.txt"
|
||||
template: "test"`
|
||||
|
||||
manifestFile := filepath.Join(templatesDir, "manifest.yaml")
|
||||
err = os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write manifest file: %v", err)
|
||||
}
|
||||
|
||||
// Create empty Masonry file
|
||||
masonryFile := filepath.Join(tempDir, "empty.masonry")
|
||||
err = os.WriteFile(masonryFile, []byte(""), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write Masonry file: %v", err)
|
||||
}
|
||||
|
||||
// Test that InterpretToFiles works (this would fail if functions aren't loaded first)
|
||||
interpreter := NewTemplateInterpreter()
|
||||
outputs, err := interpreter.InterpretToFiles(masonryFile, templatesDir, "manifest.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("InterpretToFiles failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that the custom function was executed
|
||||
testContent, exists := outputs["test.txt"]
|
||||
if !exists {
|
||||
t.Errorf("Expected output file test.txt not found")
|
||||
} else if testContent != "custom:test" {
|
||||
t.Errorf("Expected 'custom:test', got '%s'", testContent)
|
||||
}
|
||||
}
|
773
interpreter/html_interpreter.go
Normal file
773
interpreter/html_interpreter.go
Normal file
@ -0,0 +1,773 @@
|
||||
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 page elements
|
||||
for _, element := range page.Elements {
|
||||
if element.Section != nil {
|
||||
sectionHTML, err := hi.generateSectionHTML(element.Section, 2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
html.WriteString(sectionHTML)
|
||||
}
|
||||
if element.Component != nil {
|
||||
componentHTML, err := hi.generateComponentHTML(element.Component, 2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
html.WriteString(componentHTML)
|
||||
}
|
||||
}
|
||||
|
||||
html.WriteString(" </div>\n")
|
||||
|
||||
// JavaScript for interactivity
|
||||
html.WriteString(" <script>\n")
|
||||
|
||||
// Generate server configuration code that handles env vars and defaults at runtime
|
||||
html.WriteString(hi.generateServerConfigJS())
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// generateServerConfigJS generates JavaScript code that handles server configuration at runtime
|
||||
func (hi *HTMLInterpreter) generateServerConfigJS() string {
|
||||
var js strings.Builder
|
||||
|
||||
// Default API base URL
|
||||
js.WriteString(" // Server configuration\n")
|
||||
js.WriteString(" let apiHost = 'localhost';\n")
|
||||
js.WriteString(" let apiPort = 8080;\n\n")
|
||||
|
||||
if hi.server != nil {
|
||||
for _, setting := range hi.server.Settings {
|
||||
if setting.Host != nil {
|
||||
js.WriteString(hi.generateConfigValueJS("apiHost", setting.Host))
|
||||
}
|
||||
if setting.Port != nil {
|
||||
js.WriteString(hi.generateIntValueJS("apiPort", setting.Port))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
js.WriteString(" const API_BASE_URL = `http://${apiHost}:${apiPort}`;\n")
|
||||
js.WriteString(" console.log('API Base URL:', API_BASE_URL);\n")
|
||||
|
||||
return js.String()
|
||||
}
|
||||
|
||||
// generateConfigValueJS generates JavaScript code to resolve a ConfigValue at runtime
|
||||
func (hi *HTMLInterpreter) generateConfigValueJS(varName string, configValue *lang.ConfigValue) string {
|
||||
var js strings.Builder
|
||||
|
||||
if configValue.Literal != nil {
|
||||
// Simple literal assignment
|
||||
js.WriteString(fmt.Sprintf(" %s = %q;\n", varName, *configValue.Literal))
|
||||
} else if configValue.EnvVar != nil {
|
||||
// Environment variable resolution in browser (note: this is limited in browsers)
|
||||
// For client-side, we'll need to use a different approach since browsers can't access server env vars
|
||||
// We'll generate code that looks for the env var in localStorage or a global config object
|
||||
js.WriteString(fmt.Sprintf(" // Check for %s in global config or localStorage\n", configValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" if (window.CONFIG && window.CONFIG['%s']) {\n", configValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" %s = window.CONFIG['%s'];\n", varName, configValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" } else if (localStorage.getItem('%s')) {\n", configValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" %s = localStorage.getItem('%s');\n", varName, configValue.EnvVar.Name))
|
||||
|
||||
if configValue.EnvVar.Default != nil {
|
||||
js.WriteString(" } else {\n")
|
||||
js.WriteString(fmt.Sprintf(" %s = %q;\n", varName, *configValue.EnvVar.Default))
|
||||
}
|
||||
js.WriteString(" }\n")
|
||||
}
|
||||
|
||||
return js.String()
|
||||
}
|
||||
|
||||
// generateIntValueJS generates JavaScript code to resolve an IntValue at runtime
|
||||
func (hi *HTMLInterpreter) generateIntValueJS(varName string, intValue *lang.IntValue) string {
|
||||
var js strings.Builder
|
||||
|
||||
if intValue.Literal != nil {
|
||||
// Simple literal assignment
|
||||
js.WriteString(fmt.Sprintf(" %s = %d;\n", varName, *intValue.Literal))
|
||||
} else if intValue.EnvVar != nil {
|
||||
// Environment variable resolution for integers
|
||||
js.WriteString(fmt.Sprintf(" // Check for %s in global config or localStorage\n", intValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" if (window.CONFIG && window.CONFIG['%s']) {\n", intValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" const val = parseInt(window.CONFIG['%s']);\n", intValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" if (!isNaN(val)) %s = val;\n", varName))
|
||||
js.WriteString(fmt.Sprintf(" } else if (localStorage.getItem('%s')) {\n", intValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" const val = parseInt(localStorage.getItem('%s'));\n", intValue.EnvVar.Name))
|
||||
js.WriteString(fmt.Sprintf(" if (!isNaN(val)) %s = val;\n", varName))
|
||||
|
||||
if intValue.EnvVar.Default != nil {
|
||||
js.WriteString(" } else {\n")
|
||||
js.WriteString(fmt.Sprintf(" %s = %s;\n", varName, *intValue.EnvVar.Default))
|
||||
}
|
||||
js.WriteString(" }\n")
|
||||
}
|
||||
|
||||
return js.String()
|
||||
}
|
||||
|
||||
// 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.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
|
||||
}
|
65
interpreter/proto_interpreter.go
Normal file
65
interpreter/proto_interpreter.go
Normal file
@ -0,0 +1,65 @@
|
||||
package interpreter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"masonry/lang"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProtoInterpreter struct {
|
||||
entities map[string]*lang.Entity
|
||||
endpoints map[string]*lang.Endpoint
|
||||
server *lang.Server
|
||||
}
|
||||
|
||||
func NewProtoInterpreter() *ProtoInterpreter {
|
||||
return &ProtoInterpreter{
|
||||
entities: make(map[string]*lang.Entity),
|
||||
endpoints: make(map[string]*lang.Endpoint),
|
||||
}
|
||||
}
|
||||
|
||||
func (pi *ProtoInterpreter) Interpret(ast lang.AST) error {
|
||||
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Server != nil {
|
||||
pi.server = def.Server
|
||||
} else if def.Entity != nil {
|
||||
pi.entities[def.Entity.Name] = def.Entity
|
||||
} else if def.Endpoint != nil {
|
||||
key := fmt.Sprintf("%s_%s", def.Endpoint.Method, strings.ReplaceAll(def.Endpoint.Path, "/", "_"))
|
||||
pi.endpoints[key] = def.Endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// Validate references
|
||||
for _, endpoint := range pi.endpoints {
|
||||
if endpoint.Entity != nil {
|
||||
if _, exists := pi.entities[*endpoint.Entity]; !exists {
|
||||
return fmt.Errorf("undefined entity referenced in endpoint request: %s", *endpoint.Entity)
|
||||
}
|
||||
}
|
||||
if endpoint.Response != nil {
|
||||
entity, exists := pi.entities[*endpoint.Entity]
|
||||
if !exists {
|
||||
return fmt.Errorf("undefined entity referenced in endpoint response: %s", *endpoint.Entity)
|
||||
}
|
||||
// validate the fields of the response
|
||||
for _, field := range endpoint.Response.Fields {
|
||||
// check for a field with the same name in the entity
|
||||
found := false
|
||||
for _, entityField := range entity.Fields {
|
||||
if entityField.Name == field {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("undefined field referenced in endpoint response: %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
459
interpreter/server_interpreter.go
Normal file
459
interpreter/server_interpreter.go
Normal file
@ -0,0 +1,459 @@
|
||||
package interpreter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/format"
|
||||
"masonry/lang"
|
||||
"strconv"
|
||||
"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"
|
||||
"os"
|
||||
"strconv"
|
||||
"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))
|
||||
}
|
||||
}
|
||||
|
||||
// Generate server configuration code that handles env vars and defaults at runtime
|
||||
code.WriteString("\n\t// Server configuration\n")
|
||||
code.WriteString(si.generateServerConfigCode())
|
||||
|
||||
code.WriteString("\n\taddr := fmt.Sprintf(\"%s:%d\", host, port)\n")
|
||||
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()
|
||||
}
|
||||
|
||||
// generateServerConfigCode generates Go code that handles server configuration at runtime
|
||||
func (si *ServerInterpreter) generateServerConfigCode() string {
|
||||
var code strings.Builder
|
||||
|
||||
// Default values
|
||||
hostDefault := "localhost"
|
||||
portDefault := 8080
|
||||
var hostConfigValueCode, portConfigValueCode string
|
||||
|
||||
if si.server != nil {
|
||||
for _, setting := range si.server.Settings {
|
||||
if setting.Host != nil {
|
||||
if setting.Host.EnvVar != nil && setting.Host.EnvVar.Default != nil {
|
||||
hostDefault = *setting.Host.EnvVar.Default
|
||||
}
|
||||
hostConfigValueCode = si.generateConfigValueCode("host", setting.Host)
|
||||
}
|
||||
if setting.Port != nil {
|
||||
if setting.Port.EnvVar != nil && setting.Port.EnvVar.Default != nil {
|
||||
if defInt, err := strconv.Atoi(*setting.Port.EnvVar.Default); err == nil {
|
||||
portDefault = defInt
|
||||
}
|
||||
}
|
||||
portConfigValueCode = si.generateIntValueCode("port", setting.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
code.WriteString(fmt.Sprintf("\thost := \"%s\"\n", hostDefault))
|
||||
code.WriteString(fmt.Sprintf("\tport := %d\n\n", portDefault))
|
||||
code.WriteString(hostConfigValueCode)
|
||||
code.WriteString(portConfigValueCode)
|
||||
|
||||
return code.String()
|
||||
}
|
||||
|
||||
// generateConfigValueCode generates Go code to resolve a ConfigValue at runtime
|
||||
func (si *ServerInterpreter) generateConfigValueCode(varName string, configValue *lang.ConfigValue) string {
|
||||
var code strings.Builder
|
||||
|
||||
if configValue.Literal != nil {
|
||||
// Simple literal assignment
|
||||
code.WriteString(fmt.Sprintf("\t%s = %q\n", varName, *configValue.Literal))
|
||||
} else if configValue.EnvVar != nil {
|
||||
// Environment variable with optional default
|
||||
code.WriteString(fmt.Sprintf("\tif envVal := os.Getenv(%q); envVal != \"\" {\n", configValue.EnvVar.Name))
|
||||
code.WriteString(fmt.Sprintf("\t\t%s = envVal\n", varName))
|
||||
|
||||
if configValue.EnvVar.Default != nil {
|
||||
code.WriteString("\t} else {\n")
|
||||
code.WriteString(fmt.Sprintf("\t\t%s = %q\n", varName, *configValue.EnvVar.Default))
|
||||
}
|
||||
code.WriteString("\t}\n")
|
||||
}
|
||||
|
||||
return code.String()
|
||||
}
|
||||
|
||||
// generateIntValueCode generates Go code to resolve an IntValue at runtime
|
||||
func (si *ServerInterpreter) generateIntValueCode(varName string, intValue *lang.IntValue) string {
|
||||
var code strings.Builder
|
||||
|
||||
if intValue.Literal != nil {
|
||||
// Simple literal assignment
|
||||
code.WriteString(fmt.Sprintf("\t%s = %d\n", varName, *intValue.Literal))
|
||||
} else if intValue.EnvVar != nil {
|
||||
// Environment variable with optional default
|
||||
code.WriteString(fmt.Sprintf("\tif envVal := os.Getenv(%q); envVal != \"\" {\n", intValue.EnvVar.Name))
|
||||
code.WriteString(fmt.Sprintf("\t\tif val, err := strconv.Atoi(envVal); err == nil {\n"))
|
||||
code.WriteString(fmt.Sprintf("\t\t\t%s = val\n", varName))
|
||||
code.WriteString("\t\t}\n")
|
||||
|
||||
if intValue.EnvVar.Default != nil {
|
||||
code.WriteString("\t} else {\n")
|
||||
code.WriteString(fmt.Sprintf("\t\t%s = %s\n", varName, *intValue.EnvVar.Default))
|
||||
}
|
||||
code.WriteString("\t}\n")
|
||||
}
|
||||
|
||||
return code.String()
|
||||
}
|
767
interpreter/template_interpreter.go
Normal file
767
interpreter/template_interpreter.go
Normal file
@ -0,0 +1,767 @@
|
||||
package interpreter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"masonry/lang"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/robertkrimen/otto"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TemplateManifest defines the structure for multi-file template generation
|
||||
type TemplateManifest struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Functions map[string]string `yaml:"functions,omitempty"`
|
||||
Outputs []OutputFile `yaml:"outputs"`
|
||||
}
|
||||
|
||||
// OutputFile defines a single output file configuration
|
||||
type OutputFile struct {
|
||||
Path string `yaml:"path"`
|
||||
Template string `yaml:"template"`
|
||||
Condition string `yaml:"condition,omitempty"`
|
||||
Iterator string `yaml:"iterator,omitempty"`
|
||||
ItemContext string `yaml:"item_context,omitempty"`
|
||||
}
|
||||
|
||||
// TemplateInterpreter converts Masonry AST using template files
|
||||
type TemplateInterpreter struct {
|
||||
registry *TemplateRegistry
|
||||
vm *otto.Otto
|
||||
}
|
||||
|
||||
// NewTemplateInterpreter creates a new template interpreter
|
||||
func NewTemplateInterpreter() *TemplateInterpreter {
|
||||
return &TemplateInterpreter{
|
||||
registry: NewTemplateRegistry(),
|
||||
vm: otto.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// InterpretFromFile parses a Masonry file and applies a template file
|
||||
func (ti *TemplateInterpreter) InterpretFromFile(masonryFile, templateFile string) (string, error) {
|
||||
// Read the Masonry file
|
||||
masonryInput, err := os.ReadFile(masonryFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading Masonry file: %w", err)
|
||||
}
|
||||
|
||||
// Read the template file
|
||||
tmplText, err := os.ReadFile(templateFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading template file: %w", err)
|
||||
}
|
||||
|
||||
return ti.Interpret(string(masonryInput), string(tmplText))
|
||||
}
|
||||
|
||||
// InterpretFromDirectory parses a Masonry file and applies templates from a directory
|
||||
func (ti *TemplateInterpreter) InterpretFromDirectory(masonryFile, templateDir, rootTemplate string) (string, error) {
|
||||
// Load all templates from the directory
|
||||
err := ti.registry.LoadFromDirectory(templateDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error loading templates from directory: %w", err)
|
||||
}
|
||||
|
||||
// Read the Masonry file
|
||||
masonryInput, err := os.ReadFile(masonryFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading Masonry file: %w", err)
|
||||
}
|
||||
|
||||
// Get the root template content
|
||||
rootTemplatePath := filepath.Join(templateDir, rootTemplate)
|
||||
tmplText, err := os.ReadFile(rootTemplatePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading root template file: %w", err)
|
||||
}
|
||||
|
||||
return ti.Interpret(string(masonryInput), string(tmplText))
|
||||
}
|
||||
|
||||
func (ti *TemplateInterpreter) Interpret(masonryInput string, tmplText string) (string, error) {
|
||||
ast, err := lang.ParseInput(masonryInput)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing Masonry input: %w", err)
|
||||
}
|
||||
|
||||
// Create template using the unified FuncMap from the registry
|
||||
tmpl := template.Must(template.New("rootTemplate").Funcs(ti.registry.GetFuncMap()).Parse(tmplText))
|
||||
|
||||
data := struct {
|
||||
AST lang.AST
|
||||
Registry *TemplateRegistry
|
||||
}{
|
||||
AST: ast,
|
||||
Registry: ti.registry,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Execute template
|
||||
err = tmpl.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// InterpretToFiles processes a manifest and returns multiple output files
|
||||
func (ti *TemplateInterpreter) InterpretToFiles(masonryFile, templateDir, manifestFile string) (map[string]string, error) {
|
||||
// Load manifest first to get custom functions
|
||||
manifestPath := filepath.Join(templateDir, manifestFile)
|
||||
manifestData, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading manifest: %w", err)
|
||||
}
|
||||
|
||||
var manifest TemplateManifest
|
||||
err = yaml.Unmarshal(manifestData, &manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing manifest: %w", err)
|
||||
}
|
||||
|
||||
// Load custom JavaScript functions from the manifest BEFORE loading templates
|
||||
err = ti.loadCustomFunctions(manifest.Functions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading custom functions: %w", err)
|
||||
}
|
||||
|
||||
// Now load templates from directory (they will have access to custom functions)
|
||||
err = ti.registry.LoadFromDirectory(templateDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading templates: %w", err)
|
||||
}
|
||||
|
||||
// Parse Masonry input
|
||||
masonryInput, err := os.ReadFile(masonryFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading Masonry file: %w", err)
|
||||
}
|
||||
|
||||
ast, err := lang.ParseInput(string(masonryInput))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing Masonry input: %w", err)
|
||||
}
|
||||
|
||||
// Process each output file
|
||||
outputs := make(map[string]string)
|
||||
for _, output := range manifest.Outputs {
|
||||
if output.Iterator != "" {
|
||||
// Handle per-item generation
|
||||
files, err := ti.generatePerItem(output, ast)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for path, content := range files {
|
||||
outputs[path] = content
|
||||
}
|
||||
} else {
|
||||
// Handle single file generation (existing logic)
|
||||
if output.Condition != "" && !ti.evaluateCondition(output.Condition, ast) {
|
||||
continue
|
||||
}
|
||||
content, err := ti.executeTemplate(output.Template, ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error executing template %s: %w", output.Template, err)
|
||||
}
|
||||
|
||||
// Execute path template to get dynamic filename
|
||||
pathContent, err := ti.executePathTemplate(output.Path, map[string]interface{}{
|
||||
"AST": ast,
|
||||
"Registry": ti.registry,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error executing path template: %w", err)
|
||||
}
|
||||
|
||||
outputs[pathContent] = content
|
||||
}
|
||||
}
|
||||
|
||||
return outputs, nil
|
||||
}
|
||||
|
||||
// generatePerItem handles per-item iteration for multi-file generation
|
||||
func (ti *TemplateInterpreter) generatePerItem(output OutputFile, ast lang.AST) (map[string]string, error) {
|
||||
items := ti.getIteratorItems(output.Iterator, ast)
|
||||
results := make(map[string]string)
|
||||
|
||||
for _, item := range items {
|
||||
// Check condition with item context
|
||||
if output.Condition != "" && !ti.evaluateItemCondition(output.Condition, item, ast) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create template data with item context
|
||||
data := ti.createItemTemplateData(output.ItemContext, item, ast)
|
||||
|
||||
// Execute path template to get dynamic filename
|
||||
pathContent, err := ti.executePathTemplate(output.Path, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Execute content template
|
||||
content, err := ti.executeTemplateWithData(output.Template, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results[pathContent] = content
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// getIteratorItems extracts items from the AST based on the iterator type
|
||||
func (ti *TemplateInterpreter) getIteratorItems(iterator string, ast lang.AST) []interface{} {
|
||||
var items []interface{}
|
||||
|
||||
switch iterator {
|
||||
case "pages":
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Page != nil {
|
||||
items = append(items, def.Page)
|
||||
}
|
||||
}
|
||||
case "entities":
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Entity != nil {
|
||||
items = append(items, def.Entity)
|
||||
}
|
||||
}
|
||||
case "endpoints":
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Endpoint != nil {
|
||||
items = append(items, def.Endpoint)
|
||||
}
|
||||
}
|
||||
case "servers":
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Server != nil {
|
||||
items = append(items, def.Server)
|
||||
}
|
||||
}
|
||||
case "sections":
|
||||
items = ti.getAllSections(ast)
|
||||
case "components":
|
||||
items = ti.getAllComponents(ast)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// getAllSections traverses the AST to collect all sections from all pages
|
||||
func (ti *TemplateInterpreter) getAllSections(ast lang.AST) []interface{} {
|
||||
var sections []interface{}
|
||||
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Page != nil {
|
||||
// Get sections from page elements
|
||||
for i := range def.Page.Elements {
|
||||
element := &def.Page.Elements[i]
|
||||
if element.Section != nil {
|
||||
sections = append(sections, element.Section)
|
||||
// Recursively get nested sections
|
||||
sections = append(sections, ti.getSectionsFromSection(*element.Section)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// getSectionsFromSection recursively extracts sections from a single section
|
||||
func (ti *TemplateInterpreter) getSectionsFromSection(section lang.Section) []interface{} {
|
||||
var result []interface{}
|
||||
|
||||
for i := range section.Elements {
|
||||
element := §ion.Elements[i]
|
||||
if element.Section != nil {
|
||||
result = append(result, element.Section)
|
||||
// Recursively get sections from this section
|
||||
result = append(result, ti.getSectionsFromSection(*element.Section)...)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getAllComponents traverses the AST to collect all components from pages, sections, and nested components
|
||||
func (ti *TemplateInterpreter) getAllComponents(ast lang.AST) []interface{} {
|
||||
var components []interface{}
|
||||
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Page != nil {
|
||||
// Get components from page elements
|
||||
for i := range def.Page.Elements {
|
||||
element := &def.Page.Elements[i]
|
||||
if element.Component != nil {
|
||||
components = append(components, element.Component)
|
||||
// Get nested components from this component
|
||||
components = append(components, ti.getComponentsFromComponent(*element.Component)...)
|
||||
} else if element.Section != nil {
|
||||
// Get components from sections
|
||||
components = append(components, ti.getComponentsFromSection(*element.Section)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
// getComponentsFromSection recursively extracts components from a section
|
||||
func (ti *TemplateInterpreter) getComponentsFromSection(section lang.Section) []interface{} {
|
||||
var components []interface{}
|
||||
|
||||
for i := range section.Elements {
|
||||
element := §ion.Elements[i]
|
||||
if element.Component != nil {
|
||||
components = append(components, element.Component)
|
||||
// Get nested components from this component
|
||||
components = append(components, ti.getComponentsFromComponent(*element.Component)...)
|
||||
} else if element.Section != nil {
|
||||
// Recursively get components from nested sections
|
||||
components = append(components, ti.getComponentsFromSection(*element.Section)...)
|
||||
}
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
// getComponentsFromComponent recursively extracts components from nested components
|
||||
func (ti *TemplateInterpreter) getComponentsFromComponent(component lang.Component) []interface{} {
|
||||
var result []interface{}
|
||||
|
||||
for i := range component.Elements {
|
||||
element := &component.Elements[i]
|
||||
if element.Section != nil {
|
||||
// Get components from nested sections
|
||||
result = append(result, ti.getComponentsFromSection(*element.Section)...)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// createItemTemplateData creates template data with the current item in context
|
||||
func (ti *TemplateInterpreter) createItemTemplateData(itemContext string, item interface{}, ast lang.AST) map[string]interface{} {
|
||||
if itemContext == "" {
|
||||
itemContext = "Item" // default
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"AST": ast,
|
||||
"Registry": ti.registry,
|
||||
itemContext: item,
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateCondition evaluates a condition string against the AST
|
||||
func (ti *TemplateInterpreter) evaluateCondition(condition string, ast lang.AST) bool {
|
||||
switch condition {
|
||||
case "has_entities":
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Entity != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "has_endpoints":
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Endpoint != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "has_pages":
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Page != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "has_servers":
|
||||
for _, def := range ast.Definitions {
|
||||
if def.Server != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateItemCondition evaluates a condition for a specific item
|
||||
func (ti *TemplateInterpreter) evaluateItemCondition(condition string, item interface{}, ast lang.AST) bool {
|
||||
_ = ast // Mark as intentionally unused for future extensibility
|
||||
switch condition {
|
||||
case "layout_admin":
|
||||
if page, ok := item.(*lang.Page); ok {
|
||||
return page.Layout == "admin"
|
||||
}
|
||||
case "layout_public":
|
||||
if page, ok := item.(*lang.Page); ok {
|
||||
return page.Layout == "public"
|
||||
}
|
||||
case "requires_auth":
|
||||
if page, ok := item.(*lang.Page); ok {
|
||||
return page.Auth
|
||||
}
|
||||
if endpoint, ok := item.(*lang.Endpoint); ok {
|
||||
return endpoint.Auth
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// executeTemplate executes a template with the full AST context
|
||||
func (ti *TemplateInterpreter) executeTemplate(templateName string, ast lang.AST) (string, error) {
|
||||
if tmpl, exists := ti.registry.templates[templateName]; exists {
|
||||
data := struct {
|
||||
AST lang.AST
|
||||
Registry *TemplateRegistry
|
||||
}{
|
||||
AST: ast,
|
||||
Registry: ti.registry,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := tmpl.Execute(&buf, data)
|
||||
return buf.String(), err
|
||||
}
|
||||
return "", fmt.Errorf("template %s not found", templateName)
|
||||
}
|
||||
|
||||
// executeTemplateWithData executes a template with custom data
|
||||
func (ti *TemplateInterpreter) executeTemplateWithData(templateName string, data interface{}) (string, error) {
|
||||
if tmpl, exists := ti.registry.templates[templateName]; exists {
|
||||
var buf bytes.Buffer
|
||||
err := tmpl.Execute(&buf, data)
|
||||
return buf.String(), err
|
||||
}
|
||||
return "", fmt.Errorf("template %s not found", templateName)
|
||||
}
|
||||
|
||||
// executePathTemplate executes a path template to generate dynamic filenames
|
||||
func (ti *TemplateInterpreter) executePathTemplate(pathTemplate string, data interface{}) (string, error) {
|
||||
tmpl, err := template.New("pathTemplate").Funcs(ti.registry.GetFuncMap()).Parse(pathTemplate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing path template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error executing path template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
type TemplateRegistry struct {
|
||||
templates map[string]*template.Template
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
func NewTemplateRegistry() *TemplateRegistry {
|
||||
tr := &TemplateRegistry{
|
||||
templates: make(map[string]*template.Template),
|
||||
}
|
||||
|
||||
// Create funcMap with helper functions that will be available in all templates
|
||||
tr.funcMap = template.FuncMap{
|
||||
"executeTemplate": func(name string, data interface{}) (string, error) {
|
||||
if tmpl, exists := tr.templates[name]; exists {
|
||||
var buf strings.Builder
|
||||
err := tmpl.Execute(&buf, data)
|
||||
return buf.String(), err
|
||||
}
|
||||
return "", fmt.Errorf("template %s not found", name)
|
||||
},
|
||||
"hasTemplate": func(name string) bool {
|
||||
_, exists := tr.templates[name]
|
||||
return exists
|
||||
},
|
||||
"title": cases.Title(language.English).String,
|
||||
"lower": strings.ToLower,
|
||||
"upper": strings.ToUpper,
|
||||
"goType": func(t string) string {
|
||||
typeMap := map[string]string{
|
||||
"string": "string",
|
||||
"int": "int",
|
||||
"uuid": "string",
|
||||
"boolean": "bool",
|
||||
"timestamp": "time.Time",
|
||||
"text": "string",
|
||||
"object": "interface{}",
|
||||
}
|
||||
if goType, ok := typeMap[t]; ok {
|
||||
return goType
|
||||
}
|
||||
return "interface{}"
|
||||
},
|
||||
"pathToHandlerName": func(path string) string {
|
||||
// Convert "/users/{id}" to "Users"
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9]+`)
|
||||
name := re.ReplaceAllString(path, " ")
|
||||
name = strings.TrimSpace(name)
|
||||
name = cases.Title(language.English).String(name)
|
||||
return strings.ReplaceAll(name, " ", "")
|
||||
},
|
||||
"getHost": func(settings []lang.ServerSetting) string {
|
||||
for _, s := range settings {
|
||||
if s.Host != nil {
|
||||
if s.Host.Literal != nil {
|
||||
return "\"" + *s.Host.Literal + "\""
|
||||
}
|
||||
return fmt.Sprintf(`func() string { if v := os.Getenv("%s"); v != "" { return v }; return "%s" }()`, s.Host.EnvVar.Name, func() string {
|
||||
if s.Host.EnvVar.Default != nil {
|
||||
return *s.Host.EnvVar.Default
|
||||
}
|
||||
return "localhost"
|
||||
}())
|
||||
}
|
||||
}
|
||||
return "localhost"
|
||||
},
|
||||
"getPort": func(settings []lang.ServerSetting) string {
|
||||
for _, s := range settings {
|
||||
if s.Port != nil {
|
||||
if s.Port.Literal != nil {
|
||||
return strconv.Itoa(*s.Port.Literal)
|
||||
}
|
||||
return fmt.Sprintf(`func() string { if v := os.Getenv("%s"); v != "" { return v }; return "%s" }()`, s.Port.EnvVar.Name, func() string {
|
||||
if s.Port.EnvVar.Default != nil {
|
||||
return *s.Port.EnvVar.Default
|
||||
}
|
||||
return "8080"
|
||||
}())
|
||||
}
|
||||
}
|
||||
return "8080"
|
||||
},
|
||||
"getServerHostPort": func(settings []lang.ServerSetting) string {
|
||||
host := "localhost"
|
||||
port := 8080
|
||||
for _, s := range settings {
|
||||
if s.Host != nil {
|
||||
if s.Host.Literal != nil {
|
||||
host = *s.Host.Literal
|
||||
}
|
||||
if s.Host.EnvVar != nil && s.Host.EnvVar.Default != nil {
|
||||
host = *s.Host.EnvVar.Default
|
||||
}
|
||||
// If it's an env var, keep the default
|
||||
}
|
||||
if s.Port != nil {
|
||||
if s.Port.Literal != nil {
|
||||
port = *s.Port.Literal
|
||||
}
|
||||
if s.Port.EnvVar != nil && s.Port.EnvVar.Default != nil {
|
||||
if p, err := strconv.Atoi(*s.Port.EnvVar.Default); err == nil {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
// If it's an env var, keep the default
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", host, port)
|
||||
},
|
||||
"slice": func() []interface{} {
|
||||
return []interface{}{}
|
||||
},
|
||||
"append": func(slice []interface{}, item interface{}) []interface{} {
|
||||
return append(slice, item)
|
||||
},
|
||||
"add": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
"derefString": func(s *string) string {
|
||||
if s != nil {
|
||||
return *s
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"derefInt": func(i *int) int {
|
||||
if i != nil {
|
||||
return *i
|
||||
}
|
||||
return 0
|
||||
},
|
||||
"relativePrefix": func(path string) string {
|
||||
// Remove leading slash and split by "/"
|
||||
cleanPath := strings.TrimPrefix(path, "/")
|
||||
if cleanPath == "" {
|
||||
return "../"
|
||||
}
|
||||
|
||||
parts := strings.Split(cleanPath, "/")
|
||||
depth := len(parts)
|
||||
|
||||
// Build relative prefix with "../" for each level
|
||||
var prefix strings.Builder
|
||||
for i := 0; i < depth; i++ {
|
||||
prefix.WriteString("../")
|
||||
}
|
||||
|
||||
return prefix.String()
|
||||
},
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func (tr *TemplateRegistry) GetFuncMap() template.FuncMap {
|
||||
// Add the registry function to the existing funcMap
|
||||
funcMap := make(template.FuncMap)
|
||||
for k, v := range tr.funcMap {
|
||||
funcMap[k] = v
|
||||
}
|
||||
funcMap["registry"] = func() *TemplateRegistry { return tr }
|
||||
return funcMap
|
||||
}
|
||||
|
||||
func (tr *TemplateRegistry) Register(name, content string) error {
|
||||
tmpl, err := template.New(name).Funcs(tr.funcMap).Parse(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr.templates[name] = tmpl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tr *TemplateRegistry) LoadFromDirectory(dir string) error {
|
||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".tmpl") {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(path), ".tmpl")
|
||||
return tr.Register(name, string(content))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// loadCustomFunctions loads JavaScript functions into the template function map
|
||||
func (ti *TemplateInterpreter) loadCustomFunctions(functions map[string]string) error {
|
||||
if functions == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for funcName, jsCode := range functions {
|
||||
// Validate function name
|
||||
if !isValidFunctionName(funcName) {
|
||||
return fmt.Errorf("invalid function name: %s", funcName)
|
||||
}
|
||||
|
||||
// Create a wrapper function that calls the JavaScript code
|
||||
templateFunc := ti.createJavaScriptTemplateFunction(funcName, jsCode)
|
||||
ti.registry.funcMap[funcName] = templateFunc
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createJavaScriptTemplateFunction creates a Go template function that executes JavaScript
|
||||
func (ti *TemplateInterpreter) createJavaScriptTemplateFunction(funcName, jsCode string) interface{} {
|
||||
return func(args ...interface{}) (interface{}, error) {
|
||||
// Create a new VM instance for this function call to avoid conflicts
|
||||
vm := otto.New()
|
||||
|
||||
// Set up global helper functions in the JavaScript environment
|
||||
vm.Set("log", func(call otto.FunctionCall) otto.Value {
|
||||
// For debugging - could be extended to proper logging
|
||||
fmt.Printf("[JS %s]: %v\n", funcName, call.ArgumentList)
|
||||
return otto.UndefinedValue()
|
||||
})
|
||||
|
||||
// Convert Go arguments to JavaScript values
|
||||
for i, arg := range args {
|
||||
vm.Set(fmt.Sprintf("arg%d", i), arg)
|
||||
}
|
||||
|
||||
// Set a convenience 'args' array
|
||||
argsArray, _ := vm.Object("args = []")
|
||||
for i, arg := range args {
|
||||
argsArray.Set(strconv.Itoa(i), arg)
|
||||
}
|
||||
|
||||
// Execute the JavaScript function
|
||||
jsWrapper := fmt.Sprintf(`
|
||||
(function() {
|
||||
%s
|
||||
if (typeof main === 'function') {
|
||||
return main.apply(this, args);
|
||||
} else {
|
||||
throw new Error('Custom function must define a main() function');
|
||||
}
|
||||
})();
|
||||
`, jsCode)
|
||||
|
||||
result, err := vm.Run(jsWrapper)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error executing JavaScript function %s: %w", funcName, err)
|
||||
}
|
||||
|
||||
// Convert JavaScript result back to Go value
|
||||
if result.IsUndefined() || result.IsNull() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
goValue, err := result.Export()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting JavaScript result to Go value: %w", err)
|
||||
}
|
||||
|
||||
return goValue, nil
|
||||
}
|
||||
}
|
||||
|
||||
// isValidFunctionName checks if the function name is valid for Go templates
|
||||
func isValidFunctionName(name string) bool {
|
||||
// Basic validation: alphanumeric and underscore, must start with letter
|
||||
if len(name) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if !((name[0] >= 'a' && name[0] <= 'z') || (name[0] >= 'A' && name[0] <= 'Z') || name[0] == '_') {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, char := range name[1:] {
|
||||
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') ||
|
||||
(char >= '0' && char <= '9') || char == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid conflicts with built-in template functions
|
||||
builtinFunctions := map[string]bool{
|
||||
"and": true, "or": true, "not": true, "len": true, "index": true,
|
||||
"print": true, "printf": true, "println": true, "html": true, "js": true,
|
||||
"call": true, "urlquery": true, "eq": true, "ne": true, "lt": true,
|
||||
"le": true, "gt": true, "ge": true,
|
||||
}
|
||||
|
||||
return !builtinFunctions[name]
|
||||
}
|
265
interpreter/template_iterator_test.go
Normal file
265
interpreter/template_iterator_test.go
Normal file
@ -0,0 +1,265 @@
|
||||
package interpreter
|
||||
|
||||
import (
|
||||
"masonry/lang"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetIteratorItems(t *testing.T) {
|
||||
// Create a test AST with nested sections and components
|
||||
ast := createTestAST()
|
||||
|
||||
interpreter := NewTemplateInterpreter()
|
||||
|
||||
// Test pages iterator
|
||||
t.Run("pages iterator", func(t *testing.T) {
|
||||
items := interpreter.getIteratorItems("pages", ast)
|
||||
if len(items) != 2 {
|
||||
t.Errorf("Expected 2 pages, got %d", len(items))
|
||||
}
|
||||
|
||||
page1 := items[0].(*lang.Page)
|
||||
if page1.Name != "HomePage" {
|
||||
t.Errorf("Expected page name 'HomePage', got '%s'", page1.Name)
|
||||
}
|
||||
})
|
||||
|
||||
// Test entities iterator
|
||||
t.Run("entities iterator", func(t *testing.T) {
|
||||
items := interpreter.getIteratorItems("entities", ast)
|
||||
if len(items) != 1 {
|
||||
t.Errorf("Expected 1 entity, got %d", len(items))
|
||||
}
|
||||
|
||||
entity := items[0].(*lang.Entity)
|
||||
if entity.Name != "User" {
|
||||
t.Errorf("Expected entity name 'User', got '%s'", entity.Name)
|
||||
}
|
||||
})
|
||||
|
||||
// Test endpoints iterator
|
||||
t.Run("endpoints iterator", func(t *testing.T) {
|
||||
items := interpreter.getIteratorItems("endpoints", ast)
|
||||
if len(items) != 1 {
|
||||
t.Errorf("Expected 1 endpoint, got %d", len(items))
|
||||
}
|
||||
|
||||
endpoint := items[0].(*lang.Endpoint)
|
||||
if endpoint.Method != "GET" {
|
||||
t.Errorf("Expected endpoint method 'GET', got '%s'", endpoint.Method)
|
||||
}
|
||||
})
|
||||
|
||||
// Test servers iterator
|
||||
t.Run("servers iterator", func(t *testing.T) {
|
||||
items := interpreter.getIteratorItems("servers", ast)
|
||||
if len(items) != 1 {
|
||||
t.Errorf("Expected 1 server, got %d", len(items))
|
||||
}
|
||||
|
||||
server := items[0].(*lang.Server)
|
||||
if server.Name != "api" {
|
||||
t.Errorf("Expected server name 'api', got '%s'", server.Name)
|
||||
}
|
||||
})
|
||||
|
||||
// Test sections iterator - should find all nested sections
|
||||
t.Run("sections iterator", func(t *testing.T) {
|
||||
items := interpreter.getIteratorItems("sections", ast)
|
||||
// Expected sections:
|
||||
// - HomePage: hero, content
|
||||
// - AdminPage: dashboard
|
||||
// - hero has nested: banner
|
||||
// - content has nested: sidebar
|
||||
// Total: 5 sections (hero, content, banner, sidebar, dashboard)
|
||||
expectedCount := 5
|
||||
if len(items) != expectedCount {
|
||||
t.Errorf("Expected %d sections, got %d", expectedCount, len(items))
|
||||
// Print section names for debugging
|
||||
for i, item := range items {
|
||||
section := item.(*lang.Section)
|
||||
t.Logf("Section %d: %s", i, section.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that we have the expected section names
|
||||
sectionNames := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
section := item.(*lang.Section)
|
||||
sectionNames[section.Name] = true
|
||||
}
|
||||
|
||||
expectedSections := []string{"hero", "content", "banner", "sidebar", "dashboard"}
|
||||
for _, expected := range expectedSections {
|
||||
if !sectionNames[expected] {
|
||||
t.Errorf("Expected to find section '%s' but didn't", expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test components iterator - should find all nested components
|
||||
t.Run("components iterator", func(t *testing.T) {
|
||||
items := interpreter.getIteratorItems("components", ast)
|
||||
// Expected components:
|
||||
// - HomePage direct: header, footer
|
||||
// - In hero section: carousel
|
||||
// - In content section: article
|
||||
// - In sidebar section: widget
|
||||
// Total: 5 components
|
||||
expectedCount := 5
|
||||
if len(items) != expectedCount {
|
||||
t.Errorf("Expected %d components, got %d", expectedCount, len(items))
|
||||
// Print component types for debugging
|
||||
for i, item := range items {
|
||||
component := item.(*lang.Component)
|
||||
t.Logf("Component %d: %s", i, component.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that we have the expected component types
|
||||
componentTypes := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
component := item.(*lang.Component)
|
||||
componentTypes[component.Type] = true
|
||||
}
|
||||
|
||||
expectedComponents := []string{"header", "footer", "carousel", "article", "widget"}
|
||||
for _, expected := range expectedComponents {
|
||||
if !componentTypes[expected] {
|
||||
t.Errorf("Expected to find component type '%s' but didn't", expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test unknown iterator
|
||||
t.Run("unknown iterator", func(t *testing.T) {
|
||||
items := interpreter.getIteratorItems("unknown", ast)
|
||||
if len(items) != 0 {
|
||||
t.Errorf("Expected 0 items for unknown iterator, got %d", len(items))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createTestAST creates a complex test AST with nested sections and components
|
||||
func createTestAST() lang.AST {
|
||||
// Helper function to create string pointers
|
||||
strPtr := func(s string) *string { return &s }
|
||||
|
||||
return lang.AST{
|
||||
Definitions: []lang.Definition{
|
||||
// Server definition
|
||||
{
|
||||
Server: &lang.Server{
|
||||
Name: "api",
|
||||
Settings: []lang.ServerSetting{
|
||||
{
|
||||
Host: &lang.ConfigValue{
|
||||
Literal: strPtr("localhost"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Entity definition
|
||||
{
|
||||
Entity: &lang.Entity{
|
||||
Name: "User",
|
||||
Fields: []lang.Field{
|
||||
{
|
||||
Name: "name",
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Endpoint definition
|
||||
{
|
||||
Endpoint: &lang.Endpoint{
|
||||
Method: "GET",
|
||||
Path: "/api/users",
|
||||
},
|
||||
},
|
||||
// HomePage with nested sections and components
|
||||
{
|
||||
Page: &lang.Page{
|
||||
Name: "HomePage",
|
||||
Path: "/",
|
||||
Layout: "public",
|
||||
Elements: []lang.PageElement{
|
||||
{
|
||||
Component: &lang.Component{
|
||||
Type: "header",
|
||||
},
|
||||
},
|
||||
{
|
||||
Component: &lang.Component{
|
||||
Type: "footer",
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &lang.Section{
|
||||
Name: "hero",
|
||||
Type: strPtr("container"),
|
||||
Elements: []lang.SectionElement{
|
||||
{
|
||||
Component: &lang.Component{
|
||||
Type: "carousel",
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &lang.Section{
|
||||
Name: "banner",
|
||||
Type: strPtr("panel"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &lang.Section{
|
||||
Name: "content",
|
||||
Type: strPtr("container"),
|
||||
Elements: []lang.SectionElement{
|
||||
{
|
||||
Component: &lang.Component{
|
||||
Type: "article",
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &lang.Section{
|
||||
Name: "sidebar",
|
||||
Type: strPtr("panel"),
|
||||
Elements: []lang.SectionElement{
|
||||
{
|
||||
Component: &lang.Component{
|
||||
Type: "widget",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// AdminPage with simpler structure
|
||||
{
|
||||
Page: &lang.Page{
|
||||
Name: "AdminPage",
|
||||
Path: "/admin",
|
||||
Layout: "admin",
|
||||
Elements: []lang.PageElement{
|
||||
{
|
||||
Section: &lang.Section{
|
||||
Name: "dashboard",
|
||||
Type: strPtr("container"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
98
lang/debug_env_test.go
Normal file
98
lang/debug_env_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package lang
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSimpleEnvVar(t *testing.T) {
|
||||
// Test the simplest possible environment variable syntax
|
||||
input := `
|
||||
server MyApp {
|
||||
host env "HOST"
|
||||
}
|
||||
`
|
||||
|
||||
ast, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
if server.Settings[0].Host.EnvVar == nil {
|
||||
t.Fatalf("Expected environment variable")
|
||||
}
|
||||
if server.Settings[0].Host.EnvVar.Name != "HOST" {
|
||||
t.Errorf("Expected HOST, got %s", server.Settings[0].Host.EnvVar.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiteralValue(t *testing.T) {
|
||||
// Test that literal values still work
|
||||
input := `
|
||||
server MyApp {
|
||||
host "localhost"
|
||||
}
|
||||
`
|
||||
|
||||
ast, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
if server.Settings[0].Host.Literal == nil {
|
||||
t.Fatalf("Expected literal value")
|
||||
}
|
||||
if *server.Settings[0].Host.Literal != "localhost" {
|
||||
t.Errorf("Expected localhost, got %s", *server.Settings[0].Host.Literal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvVarWithDefault(t *testing.T) {
|
||||
// Test environment variable with default value
|
||||
input := `
|
||||
server MyApp {
|
||||
host env "HOST" default "localhost"
|
||||
}
|
||||
`
|
||||
|
||||
ast, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
envVar := server.Settings[0].Host.EnvVar
|
||||
if envVar == nil {
|
||||
t.Fatalf("Expected environment variable")
|
||||
}
|
||||
if envVar.Name != "HOST" {
|
||||
t.Errorf("Expected HOST, got %s", envVar.Name)
|
||||
}
|
||||
if envVar.Default == nil || *envVar.Default != "localhost" {
|
||||
t.Errorf("Expected default localhost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvVarRequired(t *testing.T) {
|
||||
// Test environment variable with required flag
|
||||
input := `
|
||||
server MyApp {
|
||||
api_key env "API_KEY" required
|
||||
}
|
||||
`
|
||||
|
||||
ast, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
envVar := server.Settings[0].APIKey.EnvVar
|
||||
if envVar == nil {
|
||||
t.Fatalf("Expected environment variable")
|
||||
}
|
||||
if !envVar.Required {
|
||||
t.Errorf("Expected required to be true")
|
||||
}
|
||||
}
|
109
lang/lang.go
109
lang/lang.go
@ -4,12 +4,12 @@ import (
|
||||
"github.com/alecthomas/participle/v2"
|
||||
)
|
||||
|
||||
// Root AST node containing all definitions
|
||||
// AST Root AST node containing all definitions
|
||||
type AST struct {
|
||||
Definitions []Definition `parser:"@@*"`
|
||||
}
|
||||
|
||||
// Union type for top-level definitions
|
||||
// Definition Union type for top-level definitions
|
||||
type Definition struct {
|
||||
Server *Server `parser:"@@"`
|
||||
Entity *Entity `parser:"| @@"`
|
||||
@ -17,25 +17,48 @@ type Definition struct {
|
||||
Page *Page `parser:"| @@"`
|
||||
}
|
||||
|
||||
// Clean server syntax
|
||||
// ConfigValue Flexible value that can be literal or environment variable
|
||||
type ConfigValue struct {
|
||||
Literal *string `parser:"@String"`
|
||||
EnvVar *EnvVar `parser:"| @@"`
|
||||
}
|
||||
|
||||
// EnvVar Environment variable configuration
|
||||
type EnvVar struct {
|
||||
Name string `parser:"'env' @String"`
|
||||
Default *string `parser:"('default' @String)?"`
|
||||
Required bool `parser:"@'required'?"`
|
||||
}
|
||||
|
||||
// Server 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)"`
|
||||
Host *ConfigValue `parser:"('host' @@)"`
|
||||
Port *IntValue `parser:"| ('port' @@)"`
|
||||
DatabaseURL *ConfigValue `parser:"| ('database_url' @@)"`
|
||||
APIKey *ConfigValue `parser:"| ('api_key' @@)"`
|
||||
SSLCert *ConfigValue `parser:"| ('ssl_cert' @@)"`
|
||||
SSLKey *ConfigValue `parser:"| ('ssl_key' @@)"`
|
||||
}
|
||||
|
||||
// Clean entity syntax with better readability
|
||||
// IntValue Similar to ConfigValue but for integers
|
||||
type IntValue struct {
|
||||
Literal *int `parser:"@Int"`
|
||||
EnvVar *EnvVar `parser:"| @@"`
|
||||
}
|
||||
|
||||
// Entity 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
|
||||
// Field Detailed field syntax
|
||||
type Field struct {
|
||||
Name string `parser:"@Ident ':'"`
|
||||
Type string `parser:"@Ident"`
|
||||
@ -45,15 +68,24 @@ type Field struct {
|
||||
Default *string `parser:"('default' @String)?"`
|
||||
Validations []Validation `parser:"@@*"`
|
||||
Relationship *Relationship `parser:"@@?"`
|
||||
Endpoints []string `parser:"('endpoints' '[' @Ident (',' @Ident)* ']')?"` // with transforms this might not be needed
|
||||
Transform []Transform `parser:"@@*"`
|
||||
}
|
||||
|
||||
// Simple validation syntax
|
||||
// Transform Field transformation specification
|
||||
type Transform struct {
|
||||
Type string `parser:"'transform' @Ident"`
|
||||
Column *string `parser:"('to' @Ident)?"`
|
||||
Direction *string `parser:"('on' @('input' | 'output' | 'both'))?"`
|
||||
}
|
||||
|
||||
// Validation Simple validation syntax
|
||||
type Validation struct {
|
||||
Type string `parser:"'validate' @Ident"`
|
||||
Value *string `parser:"@String?"`
|
||||
}
|
||||
|
||||
// Clear relationship syntax
|
||||
// Relationship Clear relationship syntax
|
||||
type Relationship struct {
|
||||
Type string `parser:"'relates' 'to' @Ident"`
|
||||
Cardinality string `parser:"'as' @('one' | 'many')"`
|
||||
@ -73,7 +105,7 @@ type Endpoint struct {
|
||||
CustomLogic *string `parser:"('custom' @String)? '}')?"` // Close block after all content
|
||||
}
|
||||
|
||||
// Clean parameter syntax
|
||||
// EndpointParam Clean parameter syntax
|
||||
type EndpointParam struct {
|
||||
Name string `parser:"'param' @Ident ':'"`
|
||||
Type string `parser:"@Ident"`
|
||||
@ -81,33 +113,38 @@ type EndpointParam struct {
|
||||
Source string `parser:"'from' @('path' | 'query' | 'body')"`
|
||||
}
|
||||
|
||||
// Response specification
|
||||
// ResponseSpec 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
|
||||
// Page 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
|
||||
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
|
||||
Elements []PageElement `parser:"@@* '}')?"` // Unified elements allowing any order
|
||||
}
|
||||
|
||||
// Meta tags for SEO
|
||||
// PageElement Unified element type for pages allowing sections and components in any order
|
||||
type PageElement struct {
|
||||
Section *Section `parser:"@@"`
|
||||
Component *Component `parser:"| @@"`
|
||||
}
|
||||
|
||||
// MetaTag 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
|
||||
// Section 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'))?"`
|
||||
@ -120,7 +157,7 @@ type Section struct {
|
||||
Elements []SectionElement `parser:"('{' @@* '}')?"` // Block-delimited elements for unambiguous nesting
|
||||
}
|
||||
|
||||
// New unified element type for sections
|
||||
// SectionElement New unified element type for sections
|
||||
type SectionElement struct {
|
||||
Attribute *SectionAttribute `parser:"@@"`
|
||||
Component *Component `parser:"| @@"`
|
||||
@ -128,7 +165,7 @@ type SectionElement struct {
|
||||
When *WhenCondition `parser:"| @@"`
|
||||
}
|
||||
|
||||
// Flexible section attributes (replaces complex config types)
|
||||
// SectionAttribute Flexible section attributes (replaces complex config types)
|
||||
type SectionAttribute struct {
|
||||
DataSource *string `parser:"('data' 'from' @String)"`
|
||||
Style *string `parser:"| ('style' @String)"`
|
||||
@ -137,14 +174,14 @@ type SectionAttribute struct {
|
||||
Theme *string `parser:"| ('theme' @String)"`
|
||||
}
|
||||
|
||||
// Simplified Component with unified attributes - reordered for better parsing
|
||||
// Component 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
|
||||
// ComponentElement 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:"| @@"`
|
||||
@ -153,7 +190,7 @@ type ComponentElement struct {
|
||||
When *WhenCondition `parser:"| @@"`
|
||||
}
|
||||
|
||||
// Simplified component attributes using key-value pattern - reordered for precedence
|
||||
// ComponentAttr 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)* ']')"`
|
||||
@ -164,14 +201,14 @@ type ComponentAttr struct {
|
||||
Validate bool `parser:"| @'validate'"`
|
||||
}
|
||||
|
||||
// Enhanced component field with detailed configuration using flexible attributes
|
||||
// ComponentField 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
|
||||
// ComponentFieldAttribute Flexible field attribute system
|
||||
type ComponentFieldAttribute struct {
|
||||
Label *string `parser:"('label' @String)"`
|
||||
Placeholder *string `parser:"| ('placeholder' @String)"`
|
||||
@ -192,18 +229,18 @@ type ComponentFieldAttribute struct {
|
||||
Validation *ComponentValidation `parser:"| @@"`
|
||||
}
|
||||
|
||||
// Field relationship for autocomplete and select fields
|
||||
// FieldRelation Field relationship for autocomplete and select fields
|
||||
type FieldRelation struct {
|
||||
Type string `parser:"'relates' 'to' @Ident"`
|
||||
}
|
||||
|
||||
// Component validation
|
||||
// ComponentValidation Component validation
|
||||
type ComponentValidation struct {
|
||||
Type string `parser:"'validate' @Ident"`
|
||||
Value *string `parser:"@String?"`
|
||||
}
|
||||
|
||||
// Enhanced WhenCondition with recursive support for both sections and components
|
||||
// WhenCondition 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')"`
|
||||
@ -214,14 +251,14 @@ type WhenCondition struct {
|
||||
Buttons []ComponentButton `parser:"@@* '}')?"` // Block-delimited for unambiguous nesting
|
||||
}
|
||||
|
||||
// Simplified button with flexible attribute ordering
|
||||
// ComponentButton 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
|
||||
// ComponentButtonAttr Flexible button attribute system - each attribute is a separate alternative
|
||||
type ComponentButtonAttr struct {
|
||||
Style *ComponentButtonStyle `parser:"@@"`
|
||||
Icon *ComponentButtonIcon `parser:"| @@"`
|
||||
@ -233,7 +270,7 @@ type ComponentButtonAttr struct {
|
||||
Via *ComponentButtonVia `parser:"| @@"`
|
||||
}
|
||||
|
||||
// Individual button attribute types
|
||||
// ComponentButtonStyle Individual button attribute types
|
||||
type ComponentButtonStyle struct {
|
||||
Value string `parser:"'style' @String"`
|
||||
}
|
||||
|
342
lang/parser_env_test.go
Normal file
342
lang/parser_env_test.go
Normal file
@ -0,0 +1,342 @@
|
||||
package lang
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerWithEnvironmentVariables(t *testing.T) {
|
||||
input := `
|
||||
server MyApp {
|
||||
host env "HOST" default "localhost"
|
||||
port env "PORT" default "8080"
|
||||
database_url env "DATABASE_URL" required
|
||||
api_key env "API_SECRET_KEY" required
|
||||
}
|
||||
`
|
||||
|
||||
ast, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
if len(ast.Definitions) != 1 {
|
||||
t.Fatalf("Expected 1 definition, got %d", len(ast.Definitions))
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
if server == nil {
|
||||
t.Fatalf("Expected server definition")
|
||||
}
|
||||
|
||||
if server.Name != "MyApp" {
|
||||
t.Errorf("Expected server name 'MyApp', got '%s'", server.Name)
|
||||
}
|
||||
|
||||
if len(server.Settings) != 4 {
|
||||
t.Fatalf("Expected 4 settings, got %d", len(server.Settings))
|
||||
}
|
||||
|
||||
// Test host setting
|
||||
hostSetting := server.Settings[0]
|
||||
if hostSetting.Host == nil {
|
||||
t.Fatalf("Expected host setting")
|
||||
}
|
||||
if hostSetting.Host.EnvVar == nil {
|
||||
t.Fatalf("Expected host to be environment variable")
|
||||
}
|
||||
if hostSetting.Host.EnvVar.Name != "HOST" {
|
||||
t.Errorf("Expected env var name 'HOST', got '%s'", hostSetting.Host.EnvVar.Name)
|
||||
}
|
||||
if hostSetting.Host.EnvVar.Default == nil || *hostSetting.Host.EnvVar.Default != "localhost" {
|
||||
t.Errorf("Expected default 'localhost'")
|
||||
}
|
||||
|
||||
// Test port setting
|
||||
portSetting := server.Settings[1]
|
||||
if portSetting.Port == nil {
|
||||
t.Fatalf("Expected port setting")
|
||||
}
|
||||
if portSetting.Port.EnvVar == nil {
|
||||
t.Fatalf("Expected port to be environment variable")
|
||||
}
|
||||
if portSetting.Port.EnvVar.Name != "PORT" {
|
||||
t.Errorf("Expected env var name 'PORT', got '%s'", portSetting.Port.EnvVar.Name)
|
||||
}
|
||||
if portSetting.Port.EnvVar.Default == nil || *portSetting.Port.EnvVar.Default != "8080" {
|
||||
t.Errorf("Expected default '8080'")
|
||||
}
|
||||
|
||||
// Test required database_url
|
||||
dbSetting := server.Settings[2]
|
||||
if dbSetting.DatabaseURL == nil {
|
||||
t.Fatalf("Expected database_url setting")
|
||||
}
|
||||
if dbSetting.DatabaseURL.EnvVar == nil {
|
||||
t.Fatalf("Expected database_url to be environment variable")
|
||||
}
|
||||
if !dbSetting.DatabaseURL.EnvVar.Required {
|
||||
t.Errorf("Expected database_url to be required")
|
||||
}
|
||||
|
||||
// Test required api_key
|
||||
apiSetting := server.Settings[3]
|
||||
if apiSetting.APIKey == nil {
|
||||
t.Fatalf("Expected api_key setting")
|
||||
}
|
||||
if apiSetting.APIKey.EnvVar == nil {
|
||||
t.Fatalf("Expected api_key to be environment variable")
|
||||
}
|
||||
if !apiSetting.APIKey.EnvVar.Required {
|
||||
t.Errorf("Expected api_key to be required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerWithMixedValues(t *testing.T) {
|
||||
input := `
|
||||
server MyApp {
|
||||
host "localhost"
|
||||
port env "PORT" default "8080"
|
||||
database_url env "DATABASE_URL" required
|
||||
}
|
||||
`
|
||||
|
||||
ast, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
if server == nil {
|
||||
t.Fatalf("Expected server definition")
|
||||
}
|
||||
|
||||
// Test literal host
|
||||
hostSetting := server.Settings[0]
|
||||
if hostSetting.Host == nil {
|
||||
t.Fatalf("Expected host setting")
|
||||
}
|
||||
if hostSetting.Host.Literal == nil {
|
||||
t.Fatalf("Expected host to be literal value")
|
||||
}
|
||||
if *hostSetting.Host.Literal != "localhost" {
|
||||
t.Errorf("Expected literal value 'localhost', got '%s'", *hostSetting.Host.Literal)
|
||||
}
|
||||
|
||||
// Test env port
|
||||
portSetting := server.Settings[1]
|
||||
if portSetting.Port == nil {
|
||||
t.Fatalf("Expected port setting")
|
||||
}
|
||||
if portSetting.Port.EnvVar == nil {
|
||||
t.Fatalf("Expected port to be environment variable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerWithLiteralPort(t *testing.T) {
|
||||
input := `
|
||||
server MyApp {
|
||||
host "localhost"
|
||||
port 8080
|
||||
}
|
||||
`
|
||||
|
||||
ast, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
portSetting := server.Settings[1]
|
||||
if portSetting.Port == nil {
|
||||
t.Fatalf("Expected port setting")
|
||||
}
|
||||
if portSetting.Port.Literal == nil {
|
||||
t.Fatalf("Expected port to be literal value")
|
||||
}
|
||||
if *portSetting.Port.Literal != 8080 {
|
||||
t.Errorf("Expected literal port 8080, got %d", *portSetting.Port.Literal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigurationsInAnyOrder(t *testing.T) {
|
||||
// Test that server settings can be defined in any order
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "host first, then port",
|
||||
input: `
|
||||
server MyApp {
|
||||
host "localhost"
|
||||
port 8080
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "port first, then host",
|
||||
input: `
|
||||
server MyApp {
|
||||
port 8080
|
||||
host "localhost"
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "mixed literal and env vars in random order",
|
||||
input: `
|
||||
server MyApp {
|
||||
api_key env "API_KEY" required
|
||||
host "localhost"
|
||||
database_url env "DATABASE_URL" default "postgres://localhost:5432/myapp"
|
||||
port 8080
|
||||
ssl_cert env "SSL_CERT" required
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "all env vars in different order",
|
||||
input: `
|
||||
server MyApp {
|
||||
ssl_key env "SSL_KEY" required
|
||||
port env "PORT" default "8080"
|
||||
database_url env "DATABASE_URL" required
|
||||
host env "HOST" default "localhost"
|
||||
api_key env "API_KEY" required
|
||||
ssl_cert env "SSL_CERT" required
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "all literal values in different order",
|
||||
input: `
|
||||
server MyApp {
|
||||
database_url "postgres://localhost:5432/myapp"
|
||||
port 3000
|
||||
ssl_key "/path/to/ssl.key"
|
||||
host "0.0.0.0"
|
||||
api_key "secret123"
|
||||
ssl_cert "/path/to/ssl.crt"
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ast, err := ParseInput(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error for %s: %v", tt.name, err)
|
||||
}
|
||||
|
||||
if len(ast.Definitions) != 1 {
|
||||
t.Fatalf("Expected 1 definition, got %d", len(ast.Definitions))
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
if server == nil {
|
||||
t.Fatalf("Expected server definition")
|
||||
}
|
||||
|
||||
if server.Name != "MyApp" {
|
||||
t.Errorf("Expected server name 'MyApp', got '%s'", server.Name)
|
||||
}
|
||||
|
||||
// Verify that we can parse any number of settings in any order
|
||||
if len(server.Settings) < 1 {
|
||||
t.Fatalf("Expected at least 1 setting, got %d", len(server.Settings))
|
||||
}
|
||||
|
||||
// Test that we can access specific settings regardless of order
|
||||
var hasHost, hasPort bool
|
||||
for _, setting := range server.Settings {
|
||||
if setting.Host != nil {
|
||||
hasHost = true
|
||||
}
|
||||
if setting.Port != nil {
|
||||
hasPort = true
|
||||
}
|
||||
}
|
||||
|
||||
// For the first two tests, verify both host and port are present
|
||||
if tt.name == "host first, then port" || tt.name == "port first, then host" {
|
||||
if !hasHost {
|
||||
t.Errorf("Expected to find host setting")
|
||||
}
|
||||
if !hasPort {
|
||||
t.Errorf("Expected to find port setting")
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Successfully parsed %d settings for test '%s'", len(server.Settings), tt.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigurationValidation(t *testing.T) {
|
||||
// Test that we can properly validate different configurations
|
||||
input := `
|
||||
server ProductionApp {
|
||||
ssl_cert env "SSL_CERT_PATH" required
|
||||
database_url env "DATABASE_URL" required
|
||||
host env "HOST" default "0.0.0.0"
|
||||
api_key env "SECRET_API_KEY" required
|
||||
port env "PORT" default "443"
|
||||
ssl_key env "SSL_KEY_PATH" required
|
||||
}
|
||||
`
|
||||
|
||||
ast, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
server := ast.Definitions[0].Server
|
||||
if server.Name != "ProductionApp" {
|
||||
t.Errorf("Expected server name 'ProductionApp', got '%s'", server.Name)
|
||||
}
|
||||
|
||||
// Create a map to easily check for specific settings
|
||||
settingsMap := make(map[string]interface{})
|
||||
for _, setting := range server.Settings {
|
||||
if setting.Host != nil {
|
||||
settingsMap["host"] = setting.Host
|
||||
}
|
||||
if setting.Port != nil {
|
||||
settingsMap["port"] = setting.Port
|
||||
}
|
||||
if setting.DatabaseURL != nil {
|
||||
settingsMap["database_url"] = setting.DatabaseURL
|
||||
}
|
||||
if setting.APIKey != nil {
|
||||
settingsMap["api_key"] = setting.APIKey
|
||||
}
|
||||
if setting.SSLCert != nil {
|
||||
settingsMap["ssl_cert"] = setting.SSLCert
|
||||
}
|
||||
if setting.SSLKey != nil {
|
||||
settingsMap["ssl_key"] = setting.SSLKey
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all expected settings are present
|
||||
expectedSettings := []string{"host", "port", "database_url", "api_key", "ssl_cert", "ssl_key"}
|
||||
for _, expected := range expectedSettings {
|
||||
if _, exists := settingsMap[expected]; !exists {
|
||||
t.Errorf("Expected to find setting '%s'", expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify host has default value
|
||||
if hostSetting, ok := settingsMap["host"].(*ConfigValue); ok {
|
||||
if hostSetting.EnvVar == nil {
|
||||
t.Errorf("Expected host to be environment variable")
|
||||
} else if hostSetting.EnvVar.Default == nil || *hostSetting.EnvVar.Default != "0.0.0.0" {
|
||||
t.Errorf("Expected host default to be '0.0.0.0'")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify ssl_cert is required
|
||||
if sslCertSetting, ok := settingsMap["ssl_cert"].(*ConfigValue); ok {
|
||||
if sslCertSetting.EnvVar == nil {
|
||||
t.Errorf("Expected ssl_cert to be environment variable")
|
||||
} else if !sslCertSetting.EnvVar.Required {
|
||||
t.Errorf("Expected ssl_cert to be required")
|
||||
}
|
||||
}
|
||||
}
|
@ -4,6 +4,35 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Helper functions for creating ConfigValue and IntValue instances
|
||||
func literalConfigValue(value string) *ConfigValue {
|
||||
return &ConfigValue{Literal: &value}
|
||||
}
|
||||
|
||||
func literalIntValue(value int) *IntValue {
|
||||
return &IntValue{Literal: &value}
|
||||
}
|
||||
|
||||
func envConfigValue(name string, defaultValue *string, required bool) *ConfigValue {
|
||||
return &ConfigValue{
|
||||
EnvVar: &EnvVar{
|
||||
Name: name,
|
||||
Default: defaultValue,
|
||||
Required: required,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func envIntValue(name string, defaultValue *string, required bool) *IntValue {
|
||||
return &IntValue{
|
||||
EnvVar: &EnvVar{
|
||||
Name: name,
|
||||
Default: defaultValue,
|
||||
Required: required,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerDefinitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@ -23,8 +52,8 @@ func TestParseServerDefinitions(t *testing.T) {
|
||||
Server: &Server{
|
||||
Name: "MyApp",
|
||||
Settings: []ServerSetting{
|
||||
{Host: stringPtr("localhost")},
|
||||
{Port: intPtr(8080)},
|
||||
{Host: literalConfigValue("localhost")},
|
||||
{Port: literalIntValue(8080)},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -43,7 +72,7 @@ func TestParseServerDefinitions(t *testing.T) {
|
||||
Server: &Server{
|
||||
Name: "WebApp",
|
||||
Settings: []ServerSetting{
|
||||
{Host: stringPtr("0.0.0.0")},
|
||||
{Host: literalConfigValue("0.0.0.0")},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -62,7 +91,7 @@ func TestParseServerDefinitions(t *testing.T) {
|
||||
Server: &Server{
|
||||
Name: "APIServer",
|
||||
Settings: []ServerSetting{
|
||||
{Port: intPtr(3000)},
|
||||
{Port: literalIntValue(3000)},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -4,13 +4,121 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAdvancedUIFeatures(t *testing.T) {
|
||||
func TestParseAdvancedUIStructures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want AST
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "complex form with validation and conditional fields",
|
||||
input: `page Test at "/test" layout main {
|
||||
component form for User {
|
||||
field name type text required validate min_length "3"
|
||||
field email type email required validate email
|
||||
field account_type type select options ["personal", "business"] default "personal"
|
||||
when account_type equals "business" {
|
||||
field company_name type text required
|
||||
field tax_id type text validate pattern "[0-9]{9}"
|
||||
}
|
||||
button submit label "Create Account" style "primary"
|
||||
button cancel label "Cancel" style "secondary"
|
||||
}
|
||||
}`,
|
||||
want: AST{
|
||||
Definitions: []Definition{
|
||||
{
|
||||
Page: &Page{
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "form",
|
||||
Entity: stringPtr("User"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "name",
|
||||
Type: "text",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Required: true},
|
||||
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "email",
|
||||
Type: "email",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Required: true},
|
||||
{Validation: &ComponentValidation{Type: "email"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "account_type",
|
||||
Type: "select",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Options: []string{"personal", "business"}},
|
||||
{Default: stringPtr("personal")},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Validation: &ComponentValidation{Type: "pattern", Value: stringPtr("[0-9]{9}")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "submit",
|
||||
Label: "Create Account",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "cancel",
|
||||
Label: "Cancel",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "secondary"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complex conditional rendering with multiple operators",
|
||||
input: `page Test at "/test" layout main {
|
||||
@ -42,86 +150,88 @@ func TestParseAdvancedUIFeatures(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Type: "form",
|
||||
Entity: stringPtr("User"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "status",
|
||||
Type: "select",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Options: []string{"active", "inactive", "pending"}},
|
||||
Component: &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: "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")},
|
||||
{
|
||||
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"}},
|
||||
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"}},
|
||||
},
|
||||
{
|
||||
When: &WhenCondition{
|
||||
Field: "status",
|
||||
Operator: "contains",
|
||||
Value: "pending",
|
||||
Fields: []ComponentField{
|
||||
{Name: "approval_date", Type: "date"},
|
||||
},
|
||||
{
|
||||
Name: "reject",
|
||||
Label: "Reject",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "danger"}},
|
||||
Buttons: []ComponentButton{
|
||||
{
|
||||
Name: "approve",
|
||||
Label: "Approve",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "reject",
|
||||
Label: "Reject",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "danger"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -203,104 +313,106 @@ func TestParseAdvancedUIFeatures(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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")},
|
||||
Component: &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: "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: "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: "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: "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: "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: "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},
|
||||
{
|
||||
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},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -332,76 +444,78 @@ func TestParseAdvancedUIFeatures(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Type: "form",
|
||||
Entity: stringPtr("Order"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "status",
|
||||
Type: "select",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Options: []string{"draft", "submitted", "approved"}},
|
||||
Component: &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: "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: "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: "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: "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"}},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "print",
|
||||
Label: "Print",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "outline"}},
|
||||
{Icon: &ComponentButtonIcon{Value: "printer"}},
|
||||
{Position: &ComponentButtonPosition{Value: "right"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -482,12 +596,12 @@ func TestParseFieldValidationTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
page := got.Definitions[0].Page
|
||||
if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 {
|
||||
if len(page.Elements) != 1 || page.Elements[0].Component == nil || len(page.Elements[0].Component.Elements) != 1 {
|
||||
t.Errorf("ParseInput() failed to parse component for validation %s", vt.validation)
|
||||
return
|
||||
}
|
||||
|
||||
element := page.Components[0].Elements[0]
|
||||
element := page.Elements[0].Component.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
|
||||
@ -527,7 +641,7 @@ func TestParseConditionalOperators(t *testing.T) {
|
||||
|
||||
// Verify the when condition was parsed correctly
|
||||
page := got.Definitions[0].Page
|
||||
component := page.Components[0]
|
||||
component := page.Elements[0].Component
|
||||
whenElement := component.Elements[1].When
|
||||
|
||||
if whenElement == nil || whenElement.Operator != op {
|
||||
|
@ -23,10 +23,12 @@ func TestParseComponentDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Type: "table",
|
||||
Entity: stringPtr("User"),
|
||||
Component: &Component{
|
||||
Type: "table",
|
||||
Entity: stringPtr("User"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -52,58 +54,60 @@ func TestParseComponentDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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},
|
||||
Component: &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: "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: "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: "avatar",
|
||||
Type: "file",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Accept: stringPtr("image/*")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "role",
|
||||
Type: "select",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Options: []string{"admin", "user", "guest"}},
|
||||
{Default: stringPtr("user")},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "role",
|
||||
Type: "select",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Options: []string{"admin", "user", "guest"}},
|
||||
{Default: stringPtr("user")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -135,70 +139,72 @@ func TestParseComponentDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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")}},
|
||||
Component: &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: "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: "category",
|
||||
Type: "autocomplete",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Relates: &FieldRelation{Type: "Category"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "tags",
|
||||
Type: "multiselect",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Source: stringPtr("tags/popular")},
|
||||
{
|
||||
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: "description",
|
||||
Type: "richtext",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "thumbnail",
|
||||
Type: "image",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Thumbnail: true},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "featured",
|
||||
Type: "checkbox",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Default: stringPtr("false")},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "thumbnail",
|
||||
Type: "image",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Thumbnail: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -227,44 +233,46 @@ func TestParseComponentDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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"}},
|
||||
Component: &Component{
|
||||
Type: "form",
|
||||
Entity: stringPtr("User"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "name",
|
||||
Type: "text",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "cancel",
|
||||
Label: "Cancel",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "secondary"}},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "save",
|
||||
Label: "Save User",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||
{Icon: &ComponentButtonIcon{Value: "save"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "delete",
|
||||
Label: "Delete",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "danger"}},
|
||||
{Confirm: &ComponentButtonConfirm{Value: "Are you sure?"}},
|
||||
{Disabled: &ComponentButtonDisabled{Value: "is_protected"}},
|
||||
{
|
||||
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"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -298,55 +306,57 @@ func TestParseComponentDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Type: "form",
|
||||
Entity: stringPtr("User"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "account_type",
|
||||
Type: "select",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Options: []string{"personal", "business"}},
|
||||
Component: &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},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tax_id",
|
||||
Type: "text",
|
||||
},
|
||||
},
|
||||
Buttons: []ComponentButton{
|
||||
{
|
||||
Name: "verify_business",
|
||||
Label: "Verify Business",
|
||||
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",
|
||||
{
|
||||
When: &WhenCondition{
|
||||
Field: "account_type",
|
||||
Operator: "equals",
|
||||
Value: "personal",
|
||||
Fields: []ComponentField{
|
||||
{
|
||||
Name: "date_of_birth",
|
||||
Type: "date",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -383,36 +393,38 @@ func TestParseComponentDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Components: []Component{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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")},
|
||||
Component: &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")},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "revenue",
|
||||
Type: "display",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Format: stringPtr("currency")},
|
||||
{Value: stringPtr("45,678")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -421,20 +433,20 @@ func TestParseComponentDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "charts",
|
||||
Type: stringPtr("container"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "chart",
|
||||
Entity: stringPtr("Analytics"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Attribute: &ComponentAttr{
|
||||
DataSource: stringPtr("analytics/monthly"),
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "charts",
|
||||
Type: stringPtr("container"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "chart",
|
||||
Entity: stringPtr("Analytics"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Attribute: &ComponentAttr{
|
||||
DataSource: stringPtr("analytics/monthly"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -461,7 +473,7 @@ func TestParseComponentDefinitions(t *testing.T) {
|
||||
return
|
||||
}
|
||||
if !astEqual(got, tt.want) {
|
||||
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||
t.Errorf("ParseInput() got = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -495,14 +507,20 @@ func TestParseComponentFieldTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
page := got.Definitions[0].Page
|
||||
if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 {
|
||||
if len(page.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)
|
||||
element := page.Elements[0]
|
||||
if element.Component == nil || len(element.Component.Elements) != 1 {
|
||||
t.Errorf("ParseInput() failed to parse component for field type %s", fieldType)
|
||||
return
|
||||
}
|
||||
|
||||
fieldElement := element.Component.Elements[0]
|
||||
if fieldElement.Field == nil || fieldElement.Field.Type != fieldType {
|
||||
t.Errorf("ParseInput() field type mismatch: got %v, want %s", fieldElement.Field, fieldType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -12,14 +12,14 @@ func TestParsePageDefinitions(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic page with minimal fields",
|
||||
input: `page Dashboard at "/dashboard" layout main`,
|
||||
name: "basic page definition",
|
||||
input: `page Home at "/" layout main`,
|
||||
want: AST{
|
||||
Definitions: []Definition{
|
||||
{
|
||||
Page: &Page{
|
||||
Name: "Dashboard",
|
||||
Path: "/dashboard",
|
||||
Name: "Home",
|
||||
Path: "/",
|
||||
Layout: "main",
|
||||
},
|
||||
},
|
||||
@ -27,17 +27,17 @@ func TestParsePageDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "page with all optional fields",
|
||||
input: `page UserProfile at "/profile" layout main title "User Profile" desc "Manage user profile settings" auth`,
|
||||
name: "page with optional fields",
|
||||
input: `page Settings at "/settings" layout main title "User Settings" desc "Manage your account settings" auth`,
|
||||
want: AST{
|
||||
Definitions: []Definition{
|
||||
{
|
||||
Page: &Page{
|
||||
Name: "UserProfile",
|
||||
Path: "/profile",
|
||||
Name: "Settings",
|
||||
Path: "/settings",
|
||||
Layout: "main",
|
||||
Title: stringPtr("User Profile"),
|
||||
Description: stringPtr("Manage user profile settings"),
|
||||
Title: stringPtr("User Settings"),
|
||||
Description: stringPtr("Manage your account settings"),
|
||||
Auth: true,
|
||||
},
|
||||
},
|
||||
@ -46,20 +46,22 @@ func TestParsePageDefinitions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "page with meta tags",
|
||||
input: `page HomePage at "/" layout main {
|
||||
meta description "Welcome to our application"
|
||||
meta keywords "app, dashboard, management"
|
||||
input: `page Settings at "/settings" layout main {
|
||||
meta description "Settings page description"
|
||||
meta keywords "settings, user, account"
|
||||
meta author "My App"
|
||||
}`,
|
||||
want: AST{
|
||||
Definitions: []Definition{
|
||||
{
|
||||
Page: &Page{
|
||||
Name: "HomePage",
|
||||
Path: "/",
|
||||
Name: "Settings",
|
||||
Path: "/settings",
|
||||
Layout: "main",
|
||||
Meta: []MetaTag{
|
||||
{Name: "description", Content: "Welcome to our application"},
|
||||
{Name: "keywords", Content: "app, dashboard, management"},
|
||||
{Name: "description", Content: "Settings page description"},
|
||||
{Name: "keywords", Content: "settings, user, account"},
|
||||
{Name: "author", Content: "My App"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -67,19 +69,17 @@ func TestParsePageDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "page with nested sections",
|
||||
name: "page with 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
|
||||
}
|
||||
component form for User
|
||||
}
|
||||
|
||||
section security label "Security" {
|
||||
component form for User {
|
||||
field password type password
|
||||
}
|
||||
component form for Security
|
||||
}
|
||||
section notifications label "Notifications" {
|
||||
component toggle for NotificationSettings
|
||||
}
|
||||
}
|
||||
}`,
|
||||
@ -90,50 +90,50 @@ func TestParsePageDefinitions(t *testing.T) {
|
||||
Name: "Settings",
|
||||
Path: "/settings",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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: "tabs",
|
||||
Type: stringPtr("tab"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "profile",
|
||||
Label: stringPtr("Profile"),
|
||||
Active: true,
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "form",
|
||||
Entity: stringPtr("User"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "security",
|
||||
Label: stringPtr("Security"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "form",
|
||||
Entity: stringPtr("User"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "password",
|
||||
Type: "password",
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "security",
|
||||
Label: stringPtr("Security"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "form",
|
||||
Entity: stringPtr("Security"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "notifications",
|
||||
Label: stringPtr("Notifications"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "toggle",
|
||||
Entity: stringPtr("NotificationSettings"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -149,71 +149,124 @@ func TestParsePageDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "page with modal and panel sections",
|
||||
input: `page ProductList at "/products" layout main {
|
||||
section main type container {
|
||||
component table for Product
|
||||
name: "page with components",
|
||||
input: `page Dashboard at "/dashboard" layout main {
|
||||
component stats for Analytics {
|
||||
field total_users type display
|
||||
field revenue type display format "currency"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
component chart for SalesData {
|
||||
data from "analytics/sales"
|
||||
}
|
||||
}`,
|
||||
want: AST{
|
||||
Definitions: []Definition{
|
||||
{
|
||||
Page: &Page{
|
||||
Name: "ProductList",
|
||||
Path: "/products",
|
||||
Name: "Dashboard",
|
||||
Path: "/dashboard",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Name: "main",
|
||||
Type: stringPtr("container"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "table",
|
||||
Entity: stringPtr("Product"),
|
||||
Component: &Component{
|
||||
Type: "stats",
|
||||
Entity: stringPtr("Analytics"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "total_users",
|
||||
Type: "display",
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "revenue",
|
||||
Type: "display",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Format: stringPtr("currency")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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"}},
|
||||
Component: &Component{
|
||||
Type: "chart",
|
||||
Entity: stringPtr("SalesData"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Attribute: &ComponentAttr{
|
||||
DataSource: stringPtr("analytics/sales"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "page with mixed sections and components",
|
||||
input: `page Home at "/" layout main {
|
||||
component hero for Banner {
|
||||
field title type display
|
||||
field subtitle type display
|
||||
}
|
||||
section content type container {
|
||||
component posts for Post {
|
||||
fields [title, excerpt, date]
|
||||
}
|
||||
}
|
||||
component newsletter for Subscription {
|
||||
field email type email required
|
||||
button subscribe label "Subscribe"
|
||||
}
|
||||
}`,
|
||||
want: AST{
|
||||
Definitions: []Definition{
|
||||
{
|
||||
Page: &Page{
|
||||
Name: "Home",
|
||||
Path: "/",
|
||||
Layout: "main",
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "hero",
|
||||
Entity: stringPtr("Banner"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "title",
|
||||
Type: "display",
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "subtitle",
|
||||
Type: "display",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "content",
|
||||
Type: stringPtr("container"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "posts",
|
||||
Entity: stringPtr("Post"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Attribute: &ComponentAttr{
|
||||
Fields: []string{"title", "excerpt", "date"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -223,28 +276,25 @@ func TestParsePageDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
Component: &Component{
|
||||
Type: "newsletter",
|
||||
Entity: stringPtr("Subscription"),
|
||||
Elements: []ComponentElement{
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "email",
|
||||
Type: "email",
|
||||
Attributes: []ComponentFieldAttribute{
|
||||
{Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "subscribe",
|
||||
Label: "Subscribe",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -264,7 +314,66 @@ func TestParsePageDefinitions(t *testing.T) {
|
||||
return
|
||||
}
|
||||
if !astEqual(got, tt.want) {
|
||||
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||
t.Errorf("ParseInput() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageLayouts(t *testing.T) {
|
||||
layouts := []string{"main", "admin", "public", "auth", "minimal", "dashboard"}
|
||||
|
||||
for _, layout := range layouts {
|
||||
t.Run("layout_"+layout, func(t *testing.T) {
|
||||
input := `page Test at "/test" layout ` + layout
|
||||
|
||||
got, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Errorf("ParseInput() failed for layout %s: %v", layout, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
|
||||
t.Errorf("ParseInput() failed to parse page for layout %s", layout)
|
||||
return
|
||||
}
|
||||
|
||||
page := got.Definitions[0].Page
|
||||
if page.Layout != layout {
|
||||
t.Errorf("ParseInput() layout mismatch: got %s, want %s", page.Layout, layout)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePagePaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"root", "/"},
|
||||
{"simple", "/about"},
|
||||
{"nested", "/admin/users"},
|
||||
{"deep_nested", "/api/v1/users/profile"},
|
||||
{"with_params", "/users/:id"},
|
||||
{"with_multiple_params", "/users/:userId/posts/:postId"},
|
||||
{"with_query", "/search?q=:query"},
|
||||
{"with_extension", "/api/users.json"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := `page Test at "` + tt.path + `" layout main`
|
||||
|
||||
got, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Errorf("ParseInput() failed for path %s: %v", tt.path, err)
|
||||
return
|
||||
}
|
||||
|
||||
page := got.Definitions[0].Page
|
||||
if page.Path != tt.path {
|
||||
t.Errorf("ParseInput() path mismatch: got %s, want %s", page.Path, tt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -276,16 +385,26 @@ func TestParsePageErrors(t *testing.T) {
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "missing layout",
|
||||
input: `page Dashboard at "/dashboard"`,
|
||||
name: "missing page name",
|
||||
input: `page at "/" layout main`,
|
||||
},
|
||||
{
|
||||
name: "missing path",
|
||||
input: `page Dashboard layout main`,
|
||||
input: `page Test layout main`,
|
||||
},
|
||||
{
|
||||
name: "missing layout",
|
||||
input: `page Test at "/"`,
|
||||
},
|
||||
{
|
||||
name: "invalid path format",
|
||||
input: `page Dashboard at dashboard layout main`,
|
||||
input: `page Test at /invalid layout main`,
|
||||
},
|
||||
{
|
||||
name: "unclosed page block",
|
||||
input: `page Test at "/" layout main {
|
||||
section test type container
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -23,10 +23,12 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Name: "main",
|
||||
Type: stringPtr("container"),
|
||||
Section: &Section{
|
||||
Name: "main",
|
||||
Type: stringPtr("container"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -46,15 +48,57 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Name: "sidebar",
|
||||
Type: stringPtr("panel"),
|
||||
Position: stringPtr("left"),
|
||||
Class: stringPtr("sidebar-nav"),
|
||||
Label: stringPtr("Navigation"),
|
||||
Trigger: stringPtr("toggle-sidebar"),
|
||||
Entity: stringPtr("User"),
|
||||
Section: &Section{
|
||||
Name: "sidebar",
|
||||
Type: stringPtr("panel"),
|
||||
Class: stringPtr("sidebar-nav"),
|
||||
Label: stringPtr("Navigation"),
|
||||
Trigger: stringPtr("toggle-sidebar"),
|
||||
Position: stringPtr("left"),
|
||||
Entity: stringPtr("User"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sections with separate attributes",
|
||||
input: `page Dashboard at "/dashboard" layout main {
|
||||
section content type container {
|
||||
data from "/api/data"
|
||||
style "padding: 20px"
|
||||
}
|
||||
}`,
|
||||
want: AST{
|
||||
Definitions: []Definition{
|
||||
{
|
||||
Page: &Page{
|
||||
Name: "Dashboard",
|
||||
Path: "/dashboard",
|
||||
Layout: "main",
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "content",
|
||||
Type: stringPtr("container"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Attribute: &SectionAttribute{
|
||||
DataSource: stringPtr("/api/data"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Attribute: &SectionAttribute{
|
||||
Style: stringPtr("padding: 20px"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -78,28 +122,30 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
Name: "tabs",
|
||||
Type: stringPtr("tab"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "overview",
|
||||
Label: stringPtr("Overview"),
|
||||
Active: true,
|
||||
Section: &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: "details",
|
||||
Label: stringPtr("Details"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "settings",
|
||||
Label: stringPtr("Settings"),
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "settings",
|
||||
Label: stringPtr("Settings"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -129,50 +175,52 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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},
|
||||
Section: &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},
|
||||
{
|
||||
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: "save",
|
||||
Label: "Save Changes",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "primary"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "cancel",
|
||||
Label: "Cancel",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "secondary"}},
|
||||
{
|
||||
Button: &ComponentButton{
|
||||
Name: "cancel",
|
||||
Label: "Cancel",
|
||||
Attributes: []ComponentButtonAttr{
|
||||
{Style: &ComponentButtonStyle{Value: "secondary"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -213,24 +261,26 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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: "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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -238,35 +288,35 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
{
|
||||
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: "email",
|
||||
Type: "email",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "bio",
|
||||
Type: "textarea",
|
||||
{
|
||||
Field: &ComponentField{
|
||||
Name: "bio",
|
||||
Type: "textarea",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -323,27 +373,29 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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: "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...")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -352,26 +404,26 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -379,31 +431,31 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -411,16 +463,16 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "reports",
|
||||
Label: stringPtr("Reports"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "table",
|
||||
Entity: stringPtr("Report"),
|
||||
{
|
||||
Section: &Section{
|
||||
Name: "reports",
|
||||
Label: stringPtr("Reports"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "table",
|
||||
Entity: stringPtr("Report"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -464,37 +516,39 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
Name: "Test",
|
||||
Path: "/test",
|
||||
Layout: "main",
|
||||
Sections: []Section{
|
||||
Elements: []PageElement{
|
||||
{
|
||||
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"),
|
||||
Section: &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"),
|
||||
{
|
||||
Name: "systemSettings",
|
||||
Type: stringPtr("container"),
|
||||
Elements: []SectionElement{
|
||||
{
|
||||
Component: &Component{
|
||||
Type: "form",
|
||||
Entity: stringPtr("Settings"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -520,7 +574,7 @@ func TestParseSectionDefinitions(t *testing.T) {
|
||||
return
|
||||
}
|
||||
if !astEqual(got, tt.want) {
|
||||
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||
t.Errorf("ParseInput() got = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -549,12 +603,12 @@ func TestParseSectionTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
page := got.Definitions[0].Page
|
||||
if len(page.Sections) != 1 {
|
||||
if len(page.Elements) != 1 || page.Elements[0].Section == nil {
|
||||
t.Errorf("ParseInput() failed to parse section for type %s", sectionType)
|
||||
return
|
||||
}
|
||||
|
||||
section := page.Sections[0]
|
||||
section := page.Elements[0].Section
|
||||
if section.Type == nil || *section.Type != sectionType {
|
||||
t.Errorf("ParseInput() section type mismatch: got %v, want %s", section.Type, sectionType)
|
||||
}
|
||||
|
@ -21,7 +21,68 @@ func serverEqual(got, want Server) bool {
|
||||
}
|
||||
|
||||
func serverSettingEqual(got, want ServerSetting) bool {
|
||||
return stringPtrEqual(got.Host, want.Host) && intPtrEqual(got.Port, want.Port)
|
||||
return configValueEqual(got.Host, want.Host) &&
|
||||
intValueEqual(got.Port, want.Port) &&
|
||||
configValueEqual(got.DatabaseURL, want.DatabaseURL) &&
|
||||
configValueEqual(got.APIKey, want.APIKey) &&
|
||||
configValueEqual(got.SSLCert, want.SSLCert) &&
|
||||
configValueEqual(got.SSLKey, want.SSLKey)
|
||||
}
|
||||
|
||||
func configValueEqual(got, want *ConfigValue) bool {
|
||||
if got == nil && want == nil {
|
||||
return true
|
||||
}
|
||||
if got == nil || want == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check literal values
|
||||
if got.Literal != nil && want.Literal != nil {
|
||||
return *got.Literal == *want.Literal
|
||||
}
|
||||
if got.Literal != nil || want.Literal != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
if got.EnvVar != nil && want.EnvVar != nil {
|
||||
return envVarEqual(*got.EnvVar, *want.EnvVar)
|
||||
}
|
||||
return got.EnvVar == nil && want.EnvVar == nil
|
||||
}
|
||||
|
||||
func intValueEqual(got, want *IntValue) bool {
|
||||
if got == nil && want == nil {
|
||||
return true
|
||||
}
|
||||
if got == nil || want == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check literal values
|
||||
if got.Literal != nil && want.Literal != nil {
|
||||
return *got.Literal == *want.Literal
|
||||
}
|
||||
if got.Literal != nil || want.Literal != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
if got.EnvVar != nil && want.EnvVar != nil {
|
||||
return envVarEqual(*got.EnvVar, *want.EnvVar)
|
||||
}
|
||||
return got.EnvVar == nil && want.EnvVar == nil
|
||||
}
|
||||
|
||||
func envVarEqual(got, want EnvVar) bool {
|
||||
if got.Name != want.Name {
|
||||
return false
|
||||
}
|
||||
if got.Required != want.Required {
|
||||
return false
|
||||
}
|
||||
return stringPtrEqual(got.Default, want.Default)
|
||||
}
|
||||
|
||||
func entityEqual(got, want Entity) bool {
|
||||
|
@ -30,24 +30,13 @@ func pageEqual(got, want Page) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Compare sections (unified model)
|
||||
if len(got.Sections) != len(want.Sections) {
|
||||
// Compare elements (unified model)
|
||||
if len(got.Elements) != len(want.Elements) {
|
||||
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]) {
|
||||
for i, element := range got.Elements {
|
||||
if !pageElementEqual(element, want.Elements[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -55,6 +44,28 @@ func pageEqual(got, want Page) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func pageElementEqual(got, want PageElement) bool {
|
||||
// Both should have either a Section or Component, but not both
|
||||
if (got.Section == nil) != (want.Section == nil) {
|
||||
return false
|
||||
}
|
||||
if (got.Component == nil) != (want.Component == nil) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare sections if present
|
||||
if got.Section != nil && want.Section != nil {
|
||||
return sectionEqual(*got.Section, *want.Section)
|
||||
}
|
||||
|
||||
// Compare components if present
|
||||
if got.Component != nil && want.Component != nil {
|
||||
return componentEqual(*got.Component, *want.Component)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func metaTagEqual(got, want MetaTag) bool {
|
||||
return got.Name == want.Name && got.Content == want.Content
|
||||
}
|
||||
|
91
lang_templates/golang/basic_go_server.tmpl
Normal file
91
lang_templates/golang/basic_go_server.tmpl
Normal file
@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Server }}
|
||||
// Server configuration
|
||||
const (
|
||||
HOST = "{{ .Server.Settings | getHost }}"
|
||||
PORT = {{ .Server.Settings | getPort }}
|
||||
)
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Entity }}
|
||||
// {{ .Entity.Name }} represents {{ .Entity.Description }}
|
||||
type {{ .Entity.Name }} struct {
|
||||
{{- range .Entity.Fields }}
|
||||
{{ .Name | title }} {{ .Type | goType }} `json:"{{ .Name }}"{{ if .Required }} validate:"required"{{ end }}`
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- $endpoints := slice }}
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Endpoint }}
|
||||
{{- $endpoints = append $endpoints . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- range $endpoints }}
|
||||
// {{ .Endpoint.Description }}
|
||||
func {{ .Endpoint.Path | pathToHandlerName }}{{ .Endpoint.Method | title }}Handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
{{- if .Endpoint.Auth }}
|
||||
// TODO: Add authentication middleware
|
||||
{{- end }}
|
||||
|
||||
{{- range .Endpoint.Params }}
|
||||
{{- if eq .Source "path" }}
|
||||
vars := mux.Vars(r)
|
||||
{{ .Name }} := vars["{{ .Name }}"]
|
||||
{{- else if eq .Source "query" }}
|
||||
{{ .Name }} := r.URL.Query().Get("{{ .Name }}")
|
||||
{{- else if eq .Source "body" }}
|
||||
var {{ .Name }} {{ .Type | goType }}
|
||||
if err := json.NewDecoder(r.Body).Decode(&{{ .Name }}); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Endpoint.CustomLogic }}
|
||||
// Custom logic: {{ .Endpoint.CustomLogic }}
|
||||
{{- else }}
|
||||
// TODO: Implement {{ .Endpoint.Method }} {{ .Endpoint.Path }} logic
|
||||
{{- end }}
|
||||
|
||||
{{- if .Endpoint.Response }}
|
||||
{{- if eq .Endpoint.Response.Type "list" }}
|
||||
response := []{{ .Endpoint.Entity }}{}
|
||||
{{- else }}
|
||||
response := {{ .Endpoint.Entity }}{}
|
||||
{{- end }}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
{{- else }}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
func main() {
|
||||
router := mux.NewRouter()
|
||||
|
||||
{{- range $endpoints }}
|
||||
router.HandleFunc("{{ .Endpoint.Path }}", {{ .Endpoint.Path | pathToHandlerName }}{{ .Endpoint.Method | title }}Handler).Methods("{{ .Endpoint.Method }}")
|
||||
{{- end }}
|
||||
|
||||
fmt.Printf("Server starting on %s:%d\n", HOST, PORT)
|
||||
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", HOST, PORT), router))
|
||||
}
|
287
lang_templates/proto/application.proto.tmpl
Normal file
287
lang_templates/proto/application.proto.tmpl
Normal file
@ -0,0 +1,287 @@
|
||||
// Code generated by Masonry DSL CLI tool. DO NOT EDIT.
|
||||
syntax = "proto3";
|
||||
|
||||
{{- $serverName := "MyService" }}
|
||||
{{- $serverHost := "localhost:8080" }}
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Server }}
|
||||
{{- $serverName = .Server.Name }}
|
||||
{{- $serverHost = getServerHostPort .Server.Settings }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
package {{ $serverName | title }};
|
||||
|
||||
import "gorm/options/gorm.proto";
|
||||
import "gorm/types/types.proto";
|
||||
import "google/api/annotations.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
option go_package = "./;pb";
|
||||
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
|
||||
info: {
|
||||
title: "{{ $serverName | title }} API"
|
||||
version: "v1.0"
|
||||
description: "Generated API for {{ $serverName }}"
|
||||
}
|
||||
host: "{{ $serverHost }}"
|
||||
};
|
||||
|
||||
service {{ $serverName | title }}Service {
|
||||
option (gorm.server).autogen = true;
|
||||
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Entity }}
|
||||
{{- $entityName := .Entity.Name }}
|
||||
|
||||
// CRUD operations for {{ $entityName }}
|
||||
rpc Create{{ $entityName }} (Create{{ $entityName }}Request) returns (Create{{ $entityName }}Response) {
|
||||
option (google.api.http) = {
|
||||
post: "/v1/{{ $entityName | title }}"
|
||||
body: "*"
|
||||
};
|
||||
}
|
||||
|
||||
rpc Read{{ $entityName }} (Read{{ $entityName }}Request) returns (Read{{ $entityName }}Response) {
|
||||
option (google.api.http) = {
|
||||
get: "/v1/{{ $entityName | title }}/{id}"
|
||||
};
|
||||
}
|
||||
|
||||
rpc List{{ $entityName }}s (List{{ $entityName }}sRequest) returns (List{{ $entityName }}sResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/v1/{{ $entityName | title }}"
|
||||
};
|
||||
}
|
||||
|
||||
rpc Update{{ $entityName }} (Update{{ $entityName }}Request) returns (Update{{ $entityName }}Response) {
|
||||
option (google.api.http) = {
|
||||
put: "/v1/{{ $entityName | title }}"
|
||||
body: "*"
|
||||
};
|
||||
}
|
||||
|
||||
rpc Delete{{ $entityName }} (Delete{{ $entityName }}Request) returns (Delete{{ $entityName }}Response) {
|
||||
option (gorm.method).object_type = "{{ $entityName }}";
|
||||
option (google.api.http) = {
|
||||
delete: "/v1/{{ $entityName | title }}/{id}"
|
||||
};
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Endpoint }}
|
||||
{{- $method := .Endpoint.Method }}
|
||||
{{- $path := .Endpoint.Path }}
|
||||
{{- $entity := .Endpoint.Entity }}
|
||||
{{- $handlerName := pathToHandlerName $path }}
|
||||
|
||||
// Custom endpoint: {{ $method }} {{ $path }}
|
||||
rpc {{ $handlerName }}{{ $method }} ({{ $handlerName }}{{ $method }}Request) returns ({{ $handlerName }}{{ $method }}Response) {
|
||||
option (google.api.http) = {
|
||||
{{- if eq $method "GET" }}
|
||||
get: "{{ $path }}"
|
||||
{{- else if eq $method "POST" }}
|
||||
post: "{{ $path }}"
|
||||
body: "*"
|
||||
{{- else if eq $method "PUT" }}
|
||||
put: "{{ $path }}"
|
||||
body: "*"
|
||||
{{- else if eq $method "PATCH" }}
|
||||
patch: "{{ $path }}"
|
||||
body: "*"
|
||||
{{- else if eq $method "DELETE" }}
|
||||
delete: "{{ $path }}"
|
||||
{{- end }}
|
||||
};
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Entity }}
|
||||
{{- $entityName := .Entity.Name }}
|
||||
|
||||
// CRUD Request/Response messages for {{ $entityName }}
|
||||
message Create{{ $entityName }}Request {
|
||||
{{ $entityName }} payload = 1;
|
||||
}
|
||||
|
||||
message Create{{ $entityName }}Response {
|
||||
{{ $entityName }} result = 1;
|
||||
}
|
||||
|
||||
message Read{{ $entityName }}Request {
|
||||
{{- $idField := "" }}
|
||||
{{- $idType := "uint64" }}
|
||||
{{- range .Entity.Fields }}
|
||||
{{- if eq .Name "id" }}
|
||||
{{- $idField = .Name }}
|
||||
{{- if eq .Type "uuid" }}
|
||||
{{- $idType = "string" }}
|
||||
{{- else if eq .Type "string" }}
|
||||
{{- $idType = "string" }}
|
||||
{{- else }}
|
||||
{{- $idType = "uint64" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{ $idType }} id = 1;
|
||||
}
|
||||
|
||||
message Read{{ $entityName }}Response {
|
||||
{{ $entityName }} result = 1;
|
||||
}
|
||||
|
||||
message List{{ $entityName }}sRequest {
|
||||
int32 page_size = 1;
|
||||
string page_token = 2;
|
||||
}
|
||||
|
||||
message List{{ $entityName }}sResponse {
|
||||
repeated {{ $entityName }} results = 1;
|
||||
string next_page_token = 2;
|
||||
}
|
||||
|
||||
message Update{{ $entityName }}Request {
|
||||
{{ $entityName }} payload = 1;
|
||||
}
|
||||
|
||||
message Update{{ $entityName }}Response {
|
||||
{{ $entityName }} result = 1;
|
||||
}
|
||||
|
||||
message Delete{{ $entityName }}Request {
|
||||
{{- $idType := "uint64" }}
|
||||
{{- range .Entity.Fields }}
|
||||
{{- if eq .Name "id" }}
|
||||
{{- if eq .Type "uuid" }}
|
||||
{{- $idType = "string" }}
|
||||
{{- else if eq .Type "string" }}
|
||||
{{- $idType = "string" }}
|
||||
{{- else }}
|
||||
{{- $idType = "uint64" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{ $idType }} id = 1;
|
||||
}
|
||||
|
||||
message Delete{{ $entityName }}Response {}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Endpoint }}
|
||||
{{- $method := .Endpoint.Method }}
|
||||
{{- $path := .Endpoint.Path }}
|
||||
{{- $handlerName := pathToHandlerName $path }}
|
||||
|
||||
// Custom endpoint messages for {{ $method }} {{ $path }}
|
||||
message {{ $handlerName }}{{ $method }}Request {
|
||||
{{- if .Endpoint.Params }}
|
||||
{{- $fieldNum := 1 }}
|
||||
{{- range .Endpoint.Params }}
|
||||
{{ goType .Type }} {{ .Name }} = {{ $fieldNum }};
|
||||
{{- $fieldNum = add $fieldNum 1 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
message {{ $handlerName }}{{ $method }}Response {
|
||||
{{- if .Endpoint.Response }}
|
||||
{{- if eq .Endpoint.Response.Type "list" }}
|
||||
repeated {{ .Endpoint.Entity }} results = 1;
|
||||
{{- else }}
|
||||
{{ .Endpoint.Response.Type }} result = 1;
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
bool success = 1;
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- range .AST.Definitions }}
|
||||
{{- if .Entity }}
|
||||
{{- $entityName := .Entity.Name }}
|
||||
|
||||
// {{ $entityName }} entity message
|
||||
message {{ $entityName }} {
|
||||
option (gorm.opts).ormable = true;
|
||||
{{- if .Entity.Description }}
|
||||
// {{ .Entity.Description }}
|
||||
{{- end }}
|
||||
|
||||
{{- $fieldNum := 1 }}
|
||||
{{- range .Entity.Fields }}
|
||||
{{- $protoType := "string" }}
|
||||
{{- $gormTags := slice }}
|
||||
|
||||
{{- if eq .Type "string" }}
|
||||
{{- $protoType = "string" }}
|
||||
{{- else if eq .Type "int" }}
|
||||
{{- $protoType = "int64" }}
|
||||
{{- else if eq .Type "uuid" }}
|
||||
{{- $protoType = "string" }}
|
||||
{{- $gormTags = append $gormTags "type:uuid" }}
|
||||
{{- else if eq .Type "boolean" }}
|
||||
{{- $protoType = "bool" }}
|
||||
{{- else if eq .Type "timestamp" }}
|
||||
{{- $protoType = "google.protobuf.Timestamp" }}
|
||||
{{- else if eq .Type "text" }}
|
||||
{{- $protoType = "string" }}
|
||||
{{- $gormTags = append $gormTags "type:text" }}
|
||||
{{- else }}
|
||||
{{- $protoType = "string" }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Required }}
|
||||
{{- $gormTags = append $gormTags "not null" }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Unique }}
|
||||
{{- $gormTags = append $gormTags "unique" }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Index }}
|
||||
{{- $gormTags = append $gormTags "index" }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Default }}
|
||||
{{- $gormTags = append $gormTags (printf "default:%s" (derefString .Default)) }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Relationship }}
|
||||
{{- if eq .Relationship.Cardinality "one" }}
|
||||
{{- if .Relationship.ForeignKey }}
|
||||
{{- $gormTags = append $gormTags (printf "foreignKey:%s" (derefString .Relationship.ForeignKey)) }}
|
||||
{{- else }}
|
||||
{{- $gormTags = append $gormTags (printf "foreignKey:%sID" .Name) }}
|
||||
{{- end }}
|
||||
{{- else if eq .Relationship.Cardinality "many" }}
|
||||
{{- $protoType = printf "repeated %s" .Relationship.Type }}
|
||||
{{- if .Relationship.Through }}
|
||||
{{- $gormTags = append $gormTags (printf "many2many:%s" (derefString .Relationship.Through)) }}
|
||||
{{- else }}
|
||||
{{- $gormTags = append $gormTags (printf "foreignKey:%sID" $entityName) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{ $protoType }} {{ .Name }} = {{ $fieldNum }}
|
||||
{{- if $gormTags }}
|
||||
[(gorm.field).tag = {
|
||||
{{- range $i, $tag := $gormTags }}
|
||||
{{- if $i }}, {{ end }}{{ $tag }}
|
||||
{{- end }}
|
||||
}]
|
||||
{{- end }};
|
||||
{{- $fieldNum = add $fieldNum 1 }}
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
12
readme.md
12
readme.md
@ -18,12 +18,22 @@ Masonry is a library that provides and implements all the basics necessary to bu
|
||||
- [ ]
|
||||
- [ ]
|
||||
|
||||
## DSL Features
|
||||
|
||||
- [x] Indicate values that are based on Environment variables
|
||||
- [x] On Entities, we should indicate what CRUD functions should be implemented instead of implementing all, or forcing the user to define each in the Endpoints section
|
||||
- [x] Yaml file to include custom template functions (Otto for JS)
|
||||
- [x] Support multi-file outputs based on a directory+manifest file.
|
||||
- [ ] Support multi-step generation. e.g. gen the proto file, use `masonry g` to get the basic server set up, gen `main.go` from a template, then update gorm overrides, etc.
|
||||
- [ ] Support field transformations on Entities using a template for `BeforeToORM` in `service.pb.gorm.override.go`
|
||||
|
||||
|
||||
## Design Philosophy (changeable...)
|
||||
|
||||
The goal of this project is to make building software for web and mobile applications as fast as possible while maintaining
|
||||
the highest level of quality and maintainability as possible.
|
||||
|
||||
* The more than can be derived from configuration the better.
|
||||
* The more that can be derived from configuration the better.
|
||||
* Pre-built functionality should be isolated into libraries and modules with *simple* interfaces to minimize manual coding.
|
||||
* Composable applications minimizes dependencies and maximizes extensibility.
|
||||
*
|
||||
|
692
temp_multi-output-template-plan.md
Normal file
692
temp_multi-output-template-plan.md
Normal file
@ -0,0 +1,692 @@
|
||||
Looking at your current `TemplateInterpreter` design, you'll need to extend it to handle multi-file output scenarios. Here are several strategies to consider:
|
||||
|
||||
## 1. Multi-Output Template Registry
|
||||
|
||||
Extend your template registry to support templates that define multiple output files:
|
||||
|
||||
```go
|
||||
type MultiFileTemplate struct {
|
||||
Name string
|
||||
OutputPath string
|
||||
Content string
|
||||
Dependencies []string
|
||||
}
|
||||
|
||||
type MultiFileRegistry struct {
|
||||
templates map[string][]MultiFileTemplate
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Template Manifest Approach
|
||||
|
||||
Create a manifest system where templates can declare their output structure:
|
||||
|
||||
```go
|
||||
type TemplateManifest struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Outputs []OutputFile `yaml:"outputs"`
|
||||
}
|
||||
|
||||
type OutputFile struct {
|
||||
Path string `yaml:"path"`
|
||||
Template string `yaml:"template"`
|
||||
Condition string `yaml:"condition,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Directory-Based Output Strategy
|
||||
|
||||
Modify your interpreter to return a map of file paths to content:
|
||||
|
||||
```go
|
||||
func (ti *TemplateInterpreter) InterpretMultiFile(masonryInput, templateDir string) (map[string]string, error) {
|
||||
// Returns map[outputPath]content
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Template Composition with File Directives
|
||||
|
||||
Add special template functions for file operations:
|
||||
|
||||
```go
|
||||
// In your funcMap
|
||||
"writeFile": func(path, content string) string {
|
||||
// Queue file for writing
|
||||
return ""
|
||||
},
|
||||
"includeFile": func(templateName string, data interface{}) string {
|
||||
// Include another template's output
|
||||
},
|
||||
```
|
||||
|
||||
## 5. Build Pipeline Approach
|
||||
|
||||
Create a build pipeline that processes templates in stages:
|
||||
|
||||
```go
|
||||
type BuildPipeline struct {
|
||||
stages []BuildStage
|
||||
}
|
||||
|
||||
type BuildStage struct {
|
||||
Name string
|
||||
Templates []string
|
||||
OutputDir string
|
||||
Condition func(ast lang.AST) bool
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended Implementation
|
||||
|
||||
I'd suggest starting with a **Template Manifest + Multi-Output** approach. This gives you flexibility while maintaining the simplicity of your current design:
|
||||
|
||||
```go
|
||||
type MultiFileInterpreter struct {
|
||||
registry *TemplateRegistry
|
||||
outputs map[string]string
|
||||
}
|
||||
|
||||
func (mfi *MultiFileInterpreter) InterpretToFiles(masonryInput, templateDir, manifestFile string) (map[string]string, error) {
|
||||
// Load manifest
|
||||
// Process each output file defined in manifest
|
||||
// Return map of filepath -> content
|
||||
}
|
||||
```
|
||||
|
||||
This approach allows you to:
|
||||
- Keep your existing single-file templates working
|
||||
- Define complex multi-file outputs via manifest files
|
||||
- Maintain template reusability across different output scenarios
|
||||
- Support conditional file generation based on AST content
|
||||
|
||||
Would you like me to elaborate on any of these strategies or help implement a specific approach?
|
||||
|
||||
Yeah, can you show me how a Template Manifest with Multi-output might work? I'd like to also see what it would look like for an end user to set up and get working.
|
||||
|
||||
I'll show you how a Template Manifest with Multi-output approach would work, including the implementation and user experience.
|
||||
|
||||
## Implementation
|
||||
|
||||
First, let's define the manifest structure:
|
||||
|
||||
```go
|
||||
type TemplateManifest struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Outputs []OutputFile `yaml:"outputs"`
|
||||
}
|
||||
|
||||
type OutputFile struct {
|
||||
Path string `yaml:"path"`
|
||||
Template string `yaml:"template"`
|
||||
Condition string `yaml:"condition,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Now let's extend your `TemplateInterpreter` to support multi-file output:
|
||||
|
||||
```go
|
||||
// InterpretToFiles processes a manifest and returns multiple output files
|
||||
func (ti *TemplateInterpreter) InterpretToFiles(masonryFile, templateDir, manifestFile string) (map[string]string, error) {
|
||||
// Load templates from directory
|
||||
err := ti.registry.LoadFromDirectory(templateDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading templates: %w", err)
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
manifestPath := filepath.Join(templateDir, manifestFile)
|
||||
manifestData, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading manifest: %w", err)
|
||||
}
|
||||
|
||||
var manifest TemplateManifest
|
||||
err = yaml.Unmarshal(manifestData, &manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing manifest: %w", err)
|
||||
}
|
||||
|
||||
// Parse Masonry input
|
||||
masonryInput, err := os.ReadFile(masonryFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading Masonry file: %w", err)
|
||||
}
|
||||
|
||||
ast, err := lang.ParseInput(string(masonryInput))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing Masonry input: %w", err)
|
||||
}
|
||||
|
||||
// Process each output file
|
||||
outputs := make(map[string]string)
|
||||
for _, output := range manifest.Outputs {
|
||||
// Check condition if specified
|
||||
if output.Condition != "" && !ti.evaluateCondition(output.Condition, ast) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute template
|
||||
content, err := ti.executeTemplate(output.Template, ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error executing template %s: %w", output.Template, err)
|
||||
}
|
||||
|
||||
outputs[output.Path] = content
|
||||
}
|
||||
|
||||
return outputs, nil
|
||||
}
|
||||
|
||||
func (ti *TemplateInterpreter) executeTemplate(templateName string, ast lang.AST) (string, error) {
|
||||
if tmpl, exists := ti.registry.templates[templateName]; exists {
|
||||
data := struct {
|
||||
AST lang.AST
|
||||
Registry *TemplateRegistry
|
||||
}{
|
||||
AST: ast,
|
||||
Registry: ti.registry,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := tmpl.Execute(&buf, data)
|
||||
return buf.String(), err
|
||||
}
|
||||
return "", fmt.Errorf("template %s not found", templateName)
|
||||
}
|
||||
|
||||
func (ti *TemplateInterpreter) evaluateCondition(condition string, ast lang.AST) bool {
|
||||
// Simple condition evaluation - you can expand this
|
||||
switch condition {
|
||||
case "has_api":
|
||||
return len(ast.APIs) > 0
|
||||
case "has_ui":
|
||||
return len(ast.UIs) > 0
|
||||
case "has_database":
|
||||
return len(ast.Databases) > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You'll also need to add the yaml import:
|
||||
|
||||
```go
|
||||
import "gopkg.in/yaml.v3"
|
||||
```
|
||||
|
||||
## End User Experience
|
||||
|
||||
Here's how an end user would set up a React component generator:
|
||||
|
||||
### 1. Project Structure
|
||||
```
|
||||
templates/react-components/
|
||||
├── manifest.yaml
|
||||
├── component.tmpl
|
||||
├── component-test.tmpl
|
||||
├── index.tmpl
|
||||
└── styles.tmpl
|
||||
```
|
||||
|
||||
### 2. Manifest File (`manifest.yaml`)
|
||||
```yaml
|
||||
name: "React Component Generator"
|
||||
description: "Generates React components with tests and styles"
|
||||
outputs:
|
||||
- path: "src/components/{{.AST.UIs.0.Name}}/{{.AST.UIs.0.Name}}.tsx"
|
||||
template: "component"
|
||||
condition: "has_ui"
|
||||
- path: "src/components/{{.AST.UIs.0.Name}}/{{.AST.UIs.0.Name}}.test.tsx"
|
||||
template: "component-test"
|
||||
condition: "has_ui"
|
||||
- path: "src/components/{{.AST.UIs.0.Name}}/index.ts"
|
||||
template: "index"
|
||||
condition: "has_ui"
|
||||
- path: "src/components/{{.AST.UIs.0.Name}}/{{.AST.UIs.0.Name}}.module.css"
|
||||
template: "styles"
|
||||
condition: "has_ui"
|
||||
```
|
||||
|
||||
### 3. Template Files
|
||||
|
||||
**`component.tmpl`:**
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import styles from './{{.AST.UIs.0.Name}}.module.css';
|
||||
|
||||
interface {{.AST.UIs.0.Name}}Props {
|
||||
{{- range .AST.UIs.0.Fields}}
|
||||
{{.Name}}: {{goType .Type}};
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
export const {{.AST.UIs.0.Name}}: React.FC<{{.AST.UIs.0.Name}}Props> = ({
|
||||
{{- range $i, $field := .AST.UIs.0.Fields}}
|
||||
{{if $i}},{{end}} {{$field.Name}}
|
||||
{{- end}}
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h2>{{.AST.UIs.0.Name}}</h2>
|
||||
{{- range .AST.UIs.0.Fields}}
|
||||
<div>{{.Name}}: {{{.Name}}}</div>
|
||||
{{- end}}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**`component-test.tmpl`:**
|
||||
```typescript
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { {{.AST.UIs.0.Name}} } from './{{.AST.UIs.0.Name}}';
|
||||
|
||||
describe('{{.AST.UIs.0.Name}}', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<{{.AST.UIs.0.Name}}{{range .AST.UIs.0.Fields}} {{.Name}}="test"{{end}} />);
|
||||
expect(screen.getByText('{{.AST.UIs.0.Name}}')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Masonry Input (`user-profile.masonry`)
|
||||
```
|
||||
ui UserProfile {
|
||||
field name: string
|
||||
field email: string
|
||||
field age: int
|
||||
}
|
||||
```
|
||||
|
||||
### 5. CLI Usage
|
||||
```bash
|
||||
# Build the CLI
|
||||
go build -o masonry.exe ./cmd/cli/
|
||||
|
||||
# Generate files using manifest
|
||||
./masonry.exe generate --input user-profile.masonry --template-dir templates/react-components --manifest manifest.yaml --output-dir ./generated
|
||||
```
|
||||
|
||||
### 6. CLI Command Implementation
|
||||
|
||||
Add this to your CLI:
|
||||
|
||||
```go
|
||||
func generateMultiFileCommand() *cobra.Command {
|
||||
var inputFile, templateDir, manifestFile, outputDir string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "generate-multi",
|
||||
Short: "Generate multiple files using a template manifest",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
interpreter := interpreter.NewTemplateInterpreter()
|
||||
|
||||
outputs, err := interpreter.InterpretToFiles(inputFile, templateDir, manifestFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write all output files
|
||||
for filePath, content := range outputs {
|
||||
fullPath := filepath.Join(outputDir, filePath)
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
dir := filepath.Dir(fullPath)
|
||||
err := os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
err = os.WriteFile(fullPath, []byte(content), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing file %s: %w", fullPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated: %s\n", fullPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&inputFile, "input", "i", "", "Input Masonry file")
|
||||
cmd.Flags().StringVarP(&templateDir, "template-dir", "t", "", "Template directory")
|
||||
cmd.Flags().StringVarP(&manifestFile, "manifest", "m", "manifest.yaml", "Manifest file name")
|
||||
cmd.Flags().StringVarP(&outputDir, "output-dir", "o", "./generated", "Output directory")
|
||||
|
||||
cmd.MarkFlagRequired("input")
|
||||
cmd.MarkFlagRequired("template-dir")
|
||||
|
||||
return cmd
|
||||
}
|
||||
```
|
||||
|
||||
This approach gives users a clean, declarative way to define complex multi-file outputs while maintaining the flexibility of your template system. The manifest acts as a blueprint that maps Masonry definitions to specific output files with conditional generation based on the AST content.
|
||||
|
||||
Okay, I think this is pretty close. There maybe a couple things missing though. What if I need a file per item in the AST? For example, I have pages defined in the DSL. So each page should be run through a single template, rather than a single template running through the whole AST. Also attributes of the page might provide some instructions for the template interpreter. Since the DSL supports named layouts, the conditions portion would be a good place to check which layout to render. Pages is only one example, But I have entities, endpoints, pages, sections, components and other future structures that will need to be supported. What suggestion do you have to make all this a possibility?
|
||||
|
||||
You need a more flexible approach that can iterate over AST collections and generate files per item. Here's how to extend the manifest system to support per-item generation:
|
||||
|
||||
## Enhanced Manifest Structure
|
||||
|
||||
```go
|
||||
type TemplateManifest struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Outputs []OutputFile `yaml:"outputs"`
|
||||
}
|
||||
|
||||
type OutputFile struct {
|
||||
Path string `yaml:"path"`
|
||||
Template string `yaml:"template"`
|
||||
Condition string `yaml:"condition,omitempty"`
|
||||
Iterator string `yaml:"iterator,omitempty"` // New: what to iterate over
|
||||
ItemContext string `yaml:"item_context,omitempty"` // New: variable name for current item
|
||||
}
|
||||
```
|
||||
|
||||
## Updated Implementation
|
||||
|
||||
```go
|
||||
func (ti *TemplateInterpreter) InterpretToFiles(masonryFile, templateDir, manifestFile string) (map[string]string, error) {
|
||||
// Load templates and parse as before...
|
||||
|
||||
outputs := make(map[string]string)
|
||||
for _, output := range manifest.Outputs {
|
||||
if output.Iterator != "" {
|
||||
// Handle per-item generation
|
||||
files, err := ti.generatePerItem(output, ast)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for path, content := range files {
|
||||
outputs[path] = content
|
||||
}
|
||||
} else {
|
||||
// Handle single file generation (existing logic)
|
||||
if output.Condition != "" && !ti.evaluateCondition(output.Condition, ast) {
|
||||
continue
|
||||
}
|
||||
content, err := ti.executeTemplate(output.Template, ast)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outputs[output.Path] = content
|
||||
}
|
||||
}
|
||||
return outputs, nil
|
||||
}
|
||||
|
||||
func (ti *TemplateInterpreter) generatePerItem(output OutputFile, ast lang.AST) (map[string]string, error) {
|
||||
items := ti.getIteratorItems(output.Iterator, ast)
|
||||
results := make(map[string]string)
|
||||
|
||||
for _, item := range items {
|
||||
// Check condition with item context
|
||||
if output.Condition != "" && !ti.evaluateItemCondition(output.Condition, item, ast) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create template data with item context
|
||||
data := ti.createItemTemplateData(output.ItemContext, item, ast)
|
||||
|
||||
// Execute path template to get dynamic filename
|
||||
pathContent, err := ti.executePathTemplate(output.Path, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Execute content template
|
||||
content, err := ti.executeTemplateWithData(output.Template, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results[pathContent] = content
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (ti *TemplateInterpreter) getIteratorItems(iterator string, ast lang.AST) []interface{} {
|
||||
switch iterator {
|
||||
case "pages":
|
||||
items := make([]interface{}, len(ast.Pages))
|
||||
for i, page := range ast.Pages {
|
||||
items[i] = page
|
||||
}
|
||||
return items
|
||||
case "entities":
|
||||
items := make([]interface{}, len(ast.Entities))
|
||||
for i, entity := range ast.Entities {
|
||||
items[i] = entity
|
||||
}
|
||||
return items
|
||||
case "endpoints":
|
||||
items := make([]interface{}, len(ast.Endpoints))
|
||||
for i, endpoint := range ast.Endpoints {
|
||||
items[i] = endpoint
|
||||
}
|
||||
return items
|
||||
case "servers":
|
||||
items := make([]interface{}, len(ast.Servers))
|
||||
for i, server := range ast.Servers {
|
||||
items[i] = server
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return []interface{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (ti *TemplateInterpreter) createItemTemplateData(itemContext string, item interface{}, ast lang.AST) interface{} {
|
||||
if itemContext == "" {
|
||||
itemContext = "Item" // default
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"AST": ast,
|
||||
"Registry": ti.registry,
|
||||
itemContext: item,
|
||||
}
|
||||
}
|
||||
|
||||
func (ti *TemplateInterpreter) evaluateItemCondition(condition string, item interface{}, ast lang.AST) bool {
|
||||
switch condition {
|
||||
case "layout_admin":
|
||||
if page, ok := item.(*lang.Page); ok {
|
||||
return page.Layout == "admin"
|
||||
}
|
||||
case "layout_public":
|
||||
if page, ok := item.(*lang.Page); ok {
|
||||
return page.Layout == "public"
|
||||
}
|
||||
case "requires_auth":
|
||||
if page, ok := item.(*lang.Page); ok {
|
||||
return page.Auth
|
||||
}
|
||||
if endpoint, ok := item.(*lang.Endpoint); ok {
|
||||
return endpoint.Auth
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
## Example User Experience
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
templates/nextjs-app/
|
||||
├── manifest.yaml
|
||||
├── page-public.tmpl
|
||||
├── page-admin.tmpl
|
||||
├── api-endpoint.tmpl
|
||||
├── entity-model.tmpl
|
||||
└── component.tmpl
|
||||
```
|
||||
|
||||
### Manifest (`manifest.yaml`)
|
||||
```yaml
|
||||
name: "Next.js Full App Generator"
|
||||
description: "Generates complete Next.js application with pages, API routes, and models"
|
||||
outputs:
|
||||
# Generate a page file per page definition
|
||||
- path: "pages/{{.Page.Path}}.tsx"
|
||||
template: "page-public"
|
||||
iterator: "pages"
|
||||
item_context: "Page"
|
||||
condition: "layout_public"
|
||||
|
||||
- path: "pages/admin/{{.Page.Name | lower}}.tsx"
|
||||
template: "page-admin"
|
||||
iterator: "pages"
|
||||
item_context: "Page"
|
||||
condition: "layout_admin"
|
||||
|
||||
# Generate API route per endpoint
|
||||
- path: "pages/api{{.Endpoint.Path}}.ts"
|
||||
template: "api-endpoint"
|
||||
iterator: "endpoints"
|
||||
item_context: "Endpoint"
|
||||
|
||||
# Generate model per entity
|
||||
- path: "lib/models/{{.Entity.Name | lower}}.ts"
|
||||
template: "entity-model"
|
||||
iterator: "entities"
|
||||
item_context: "Entity"
|
||||
|
||||
# Single files (existing behavior)
|
||||
- path: "lib/database.ts"
|
||||
template: "database-config"
|
||||
condition: "has_entities"
|
||||
```
|
||||
|
||||
### Template Files
|
||||
|
||||
**`page-public.tmpl`:**
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import Layout from '../components/Layout';
|
||||
|
||||
export default function {{.Page.Name}}Page() {
|
||||
return (
|
||||
<Layout title="{{.Page.Title | derefString}}">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1>{{.Page.Name}}</h1>
|
||||
{{if .Page.Auth}}
|
||||
{/* Protected content */}
|
||||
{{end}}
|
||||
|
||||
{{range .Page.Elements}}
|
||||
{{if .Section}}
|
||||
<section className="{{.Section.Class | derefString}}">
|
||||
{{if .Section.Label}}<h2>{{.Section.Label | derefString}}</h2>{{end}}
|
||||
{{range .Section.Elements}}
|
||||
{{if .Component}}
|
||||
{/* Component: {{.Component.Type}} */}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{else if .Component}}
|
||||
{/* Component: {{.Component.Type}} */}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**`api-endpoint.tmpl`:**
|
||||
```typescript
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
{{if .Endpoint.Auth}}
|
||||
import { requireAuth } from '../../lib/auth';
|
||||
{{end}}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
{{if .Endpoint.Auth}}
|
||||
const user = await requireAuth(req, res);
|
||||
if (!user) return;
|
||||
{{end}}
|
||||
|
||||
if (req.method === '{{.Endpoint.Method}}') {
|
||||
{{range .Endpoint.Params}}
|
||||
const {{.Name}} = req.{{if eq .Source "body"}}body{{else if eq .Source "query"}}query{{else}}params{{end}}.{{.Name}};
|
||||
{{if .Required}}
|
||||
if (!{{.Name}}) {
|
||||
return res.status(400).json({ error: '{{.Name}} is required' });
|
||||
}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
try {
|
||||
{{if .Endpoint.CustomLogic}}
|
||||
// Custom logic: {{.Endpoint.CustomLogic | derefString}}
|
||||
{{else}}
|
||||
// Default CRUD operation for {{.Endpoint.Entity | derefString}}
|
||||
{{end}}
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else {
|
||||
res.setHeader('Allow', ['{{.Endpoint.Method}}']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Masonry Input (`blog-app.masonry`)
|
||||
```
|
||||
page Home at "/" layout public {
|
||||
title "Welcome to My Blog"
|
||||
section hero type container {
|
||||
component banner for Post
|
||||
}
|
||||
}
|
||||
|
||||
page AdminDashboard at "/dashboard" layout admin auth {
|
||||
title "Admin Dashboard"
|
||||
section stats type container {
|
||||
component table for Post
|
||||
}
|
||||
}
|
||||
|
||||
entity Post {
|
||||
title: string required
|
||||
content: text required
|
||||
published: boolean default "false"
|
||||
}
|
||||
|
||||
endpoint GET "/api/posts" for Post {
|
||||
returns Post as "json"
|
||||
}
|
||||
|
||||
endpoint POST "/api/posts" for Post auth {
|
||||
param title: string required from body
|
||||
param content: string required from body
|
||||
returns Post as "json"
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
./masonry.exe generate-multi --input blog-app.masonry --template-dir templates/nextjs-app --manifest manifest.yaml --output-dir ./my-blog-app
|
||||
```
|
||||
|
||||
This would generate:
|
||||
- `pages/index.tsx` (Home page)
|
||||
- `pages/admin/admindashboard.tsx` (Admin page)
|
||||
- `pages/api/posts.ts` (GET endpoint)
|
||||
- `pages/api/posts.ts` (POST endpoint - you'd need to handle method routing)
|
||||
- `lib/models/post.ts` (Post model)
|
||||
|
||||
This approach gives you the flexibility to generate files per AST item while still supporting single-file generation and complex conditional logic based on item attributes.
|
Reference in New Issue
Block a user