21 Commits
dsl-lib ... dsl

Author SHA1 Message Date
6dfe71894d add tests for custom functions 2025-09-09 23:14:03 -06:00
a899fd6c4d add custom js functions for templates 2025-09-09 22:56:39 -06:00
b82e22c38d allow for sections and components in any order on pages 2025-09-09 22:30:00 -06:00
88d757546a implement a multi-file template interpreter 2025-09-05 01:46:26 -06:00
0bccd28134 refactor template functions to remove duplication
update readme TODOs
update plan for multi-file outputs
2025-09-04 23:53:16 -06:00
29314de3dd add some todos to the readme 2025-09-02 01:17:52 -06:00
7b84b7e372 add a rough plan for multi-file output template processing 2025-09-02 00:59:29 -06:00
99551887ea mark env vars as complete in the readme 2025-09-02 00:56:24 -06:00
69f507f176 add support for env variables to the DSL 2025-09-02 00:54:38 -06:00
c6f14e1787 update proto generation from DSL 2025-09-01 15:57:22 -06:00
ca0736b92c add instructions for AI and a readme for examples 2025-09-01 15:02:32 -06:00
48579844f3 fixed template interpreter after AI flub 2025-09-01 14:30:03 -06:00
382129d2bb working interpreter for template files 2025-09-01 13:57:09 -06:00
23e84c263d WIP: start the proto interpreter 2025-09-01 11:09:49 -06:00
d36e1bfd86 add html and server interpreters
these are basic and lack most features.
the server seems to work the best.
the html on the other hand is really rough and doesn't seem to work yet.
but it does build the pages and they have all the shapes and sections we
wanted. More work to come. :)
2025-08-25 00:50:55 -06:00
cf3ad736b7 add an html interpreter 2025-08-25 00:10:18 -06:00
e71b1c3a23 add bracket syntax replace tests 2025-08-24 23:25:43 -06:00
4ac93ee924 update the syntax highlight for example.masonry 2025-08-22 01:17:16 -06:00
1ee8de23da split tests into separate files 2025-08-22 00:59:14 -06:00
da43647b54 improve the page, sections, components 2025-08-22 00:51:55 -06:00
e28b6c89ef move files into an examples dir 2025-08-20 00:17:40 -06:00
62 changed files with 10219 additions and 1581 deletions

12
.github/copilot-instructions.md vendored Normal file
View 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
View 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/

File diff suppressed because one or more lines are too long

View File

@ -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{

View File

@ -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
},
}
}

View File

@ -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
// }

157
debug.go
View File

@ -1,157 +0,0 @@
package main
import (
"fmt"
"io/ioutil"
"masonry/lang"
)
func main() {
// Read the example.masonry file
content, err := ioutil.ReadFile("example.masonry")
if err != nil {
fmt.Printf("Error reading example.masonry: %v\n", err)
return
}
input := string(content)
ast, err := lang.ParseInput(input)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("🎉 Successfully parsed complete DSL with pages!\n\n")
for _, def := range ast.Definitions {
if def.Server != nil {
fmt.Printf("📡 Server: %s\n", def.Server.Name)
for _, setting := range def.Server.Settings {
if setting.Host != nil {
fmt.Printf(" host: %s\n", *setting.Host)
}
if setting.Port != nil {
fmt.Printf(" port: %d\n", *setting.Port)
}
}
fmt.Printf("\n")
}
if def.Entity != nil {
entity := def.Entity
fmt.Printf("🏗️ Entity: %s", entity.Name)
if entity.Description != nil {
fmt.Printf(" - %s", *entity.Description)
}
fmt.Printf("\n")
for _, field := range entity.Fields {
fmt.Printf(" %s: %s", field.Name, field.Type)
if field.Required {
fmt.Printf(" (required)")
}
if field.Unique {
fmt.Printf(" (unique)")
}
if field.Default != nil {
fmt.Printf(" default=%s", *field.Default)
}
fmt.Printf("\n")
}
fmt.Printf("\n")
}
if def.Endpoint != nil {
endpoint := def.Endpoint
fmt.Printf("🚀 Endpoint: %s %s", endpoint.Method, endpoint.Path)
if endpoint.Entity != nil {
fmt.Printf(" (for %s)", *endpoint.Entity)
}
if endpoint.Description != nil {
fmt.Printf(" - %s", *endpoint.Description)
}
if endpoint.Auth {
fmt.Printf(" [AUTH]")
}
fmt.Printf("\n\n")
}
if def.Page != nil {
page := def.Page
fmt.Printf("🎨 Page: %s at %s", page.Name, page.Path)
if page.Title != nil {
fmt.Printf(" - %s", *page.Title)
}
if page.Auth {
fmt.Printf(" [AUTH]")
}
fmt.Printf("\n")
fmt.Printf(" Layout: %s\n", page.Layout)
for _, meta := range page.Meta {
fmt.Printf(" Meta %s: %s\n", meta.Name, meta.Content)
}
for _, comp := range page.Components {
fmt.Printf(" 📦 Component: %s", comp.Type)
if comp.Entity != nil {
fmt.Printf(" for %s", *comp.Entity)
}
fmt.Printf("\n")
for _, attr := range comp.Config {
if attr.Fields != nil {
fmt.Printf(" fields: %v\n", attr.Fields.Fields)
}
if attr.Actions != nil {
fmt.Printf(" actions: ")
for i, action := range attr.Actions.Actions {
if i > 0 {
fmt.Printf(", ")
}
fmt.Printf("%s", action.Name)
if action.Endpoint != nil {
fmt.Printf(" via %s", *action.Endpoint)
}
}
fmt.Printf("\n")
}
if attr.DataSource != nil {
fmt.Printf(" data from: %s\n", attr.DataSource.Endpoint)
}
if attr.Style != nil {
fmt.Printf(" style: %s", *attr.Style.Theme)
if len(attr.Style.Classes) > 0 {
fmt.Printf(" classes: %v", attr.Style.Classes)
}
fmt.Printf("\n")
}
if attr.Pagination != nil {
fmt.Printf(" pagination: enabled")
if attr.Pagination.PageSize != nil {
fmt.Printf(" size %d", *attr.Pagination.PageSize)
}
fmt.Printf("\n")
}
if attr.Filters != nil {
fmt.Printf(" filters: ")
for i, filter := range attr.Filters.Filters {
if i > 0 {
fmt.Printf(", ")
}
fmt.Printf("%s as %s", filter.Field, filter.Type)
if filter.Label != nil {
fmt.Printf(" (%s)", *filter.Label)
}
}
fmt.Printf("\n")
}
if attr.Validation {
fmt.Printf(" validation: enabled\n")
}
}
}
fmt.Printf("\n")
}
}
}
}

View File

@ -1,125 +0,0 @@
// Example Masonry DSL definition
// This demonstrates the comprehensive language structure
// Server configuration
server MyApp host "localhost" port 8080
// Entity definitions with various field types and relationships
entity User desc "User account management"
id: uuid required unique
email: string required validate email validate min_length "5"
name: string default "Anonymous"
created_at: timestamp default "now()"
profile_id: uuid relates to Profile as one via "user_id"
entity Profile desc "User profile information"
id: uuid required unique
user_id: uuid required relates to User as one
bio: text validate max_length "500"
avatar_url: string validate url
updated_at: timestamp
posts: uuid relates to Post as many
entity Post desc "Blog posts"
id: uuid required unique
title: string required validate min_length "1" validate max_length "200"
content: text required
author_id: uuid required relates to User as one
published: boolean default "false"
created_at: timestamp default "now()"
tags: uuid relates to Tag as many through "post_tags"
entity Tag desc "Content tags"
id: uuid required unique
name: string required unique validate min_length "1" validate max_length "50"
slug: string required unique indexed
created_at: timestamp default "now()"
// API Endpoints with different HTTP methods and parameter sources
endpoint GET "/users" for User desc "List users" auth
param page: int from query
param limit: int required from query
returns list as "json" fields [id, email, name]
endpoint POST "/users" for User desc "Create user"
param user_data: object required from body
returns object as "json" fields [id, email, name]
endpoint PUT "/users/{id}" for User desc "Update user"
param id: uuid required from path
param user_data: object required from body
returns object
custom "update_user_logic"
endpoint DELETE "/users/{id}" for User desc "Delete user" auth
param id: uuid required from path
returns object
endpoint GET "/posts" for Post desc "List posts"
param author_id: uuid from query
param published: boolean from query
param page: int from query
returns list as "json" fields [id, title, author_id, published]
endpoint POST "/posts" for Post desc "Create post" auth
param post_data: object required from body
returns object fields [id, title, content, author_id]
// Frontend pages with components
page UserManagement at "/admin/users" layout AdminLayout title "User Management" auth
meta description "Manage system users"
meta keywords "users, admin, management"
component Table for User
fields [email, name, id]
actions [edit via "/users/{id}", delete via "/users/{id}", create via "/users"]
data from "/users"
style modern classes ["table-striped", "table-hover"]
pagination size 20
filters [email as text label "Search email", name as text label "Search name"]
validate
component Form for User
fields [email, name]
actions [save via "/users", cancel]
style clean
validate
page UserList at "/users" layout MainLayout title "Users"
meta description "Browse all users"
component Table for User
fields [email, name]
data from "/users"
pagination size 10
filters [name as text label "Search by name"]
page PostManagement at "/admin/posts" layout AdminLayout title "Post Management" auth
meta description "Manage blog posts"
meta keywords "posts, blog, content"
component Table for Post
fields [title, author_id, published, created_at]
actions [edit via "/posts/{id}", delete via "/posts/{id}", create via "/posts"]
data from "/posts"
style modern
pagination size 15
filters [title as text label "Search title", published as select label "Published status"]
validate
page CreatePost at "/posts/new" layout MainLayout title "Create Post" auth
component Form for Post
fields [title, content]
actions [save via "/posts", cancel]
style clean
validate
page BlogList at "/blog" layout PublicLayout title "Blog Posts"
meta description "Read our latest blog posts"
meta keywords "blog, articles, content"
component Table for Post
fields [title, created_at]
data from "/posts"
pagination size 5
filters [title as text label "Search posts"]

View 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"
}

View 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

View 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)
}

View 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"

View 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
}

View 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
}

150
examples/lang/debug.go Normal file
View File

@ -0,0 +1,150 @@
package main
import (
"fmt"
"masonry/lang"
"os"
)
func main() {
// Read the example.masonry file from the correct path
content, err := os.ReadFile("examples/lang/example.masonry")
if err != nil {
fmt.Printf("Error reading example.masonry: %v\n", err)
return
}
input := string(content)
// Try to parse the DSL
ast, err := lang.ParseInput(input)
if err != nil {
fmt.Printf("❌ Parse Error: %v\n", err)
return
}
// If we get here, parsing was successful!
fmt.Printf("🎉 Successfully parsed DSL with block delimiters!\n\n")
// Count what we parsed
var servers, entities, endpoints, pages int
for _, def := range ast.Definitions {
if def.Server != nil {
servers++
}
if def.Entity != nil {
entities++
}
if def.Endpoint != nil {
endpoints++
}
if def.Page != nil {
pages++
}
}
fmt.Printf("📊 Parsing Summary:\n")
fmt.Printf(" Servers: %d\n", servers)
fmt.Printf(" Entities: %d\n", entities)
fmt.Printf(" Endpoints: %d\n", endpoints)
fmt.Printf(" Pages: %d\n", pages)
fmt.Printf(" Total Definitions: %d\n", len(ast.Definitions))
// Verify key structures parsed correctly
fmt.Printf("\n✅ Validation Results:\n")
// Check server has settings in block
for _, def := range ast.Definitions {
if def.Server != nil {
if len(def.Server.Settings) > 0 {
fmt.Printf(" ✓ Server '%s' has %d settings (block syntax working)\n", def.Server.Name, len(def.Server.Settings))
}
break
}
}
// Check entities have fields in blocks
entityCount := 0
for _, def := range ast.Definitions {
if def.Entity != nil {
entityCount++
if len(def.Entity.Fields) > 0 {
fmt.Printf(" ✓ Entity '%s' has %d fields (block syntax working)\n", def.Entity.Name, len(def.Entity.Fields))
}
if entityCount >= 2 { // Just show first couple
break
}
}
}
// Check endpoints have params in blocks
endpointCount := 0
for _, def := range ast.Definitions {
if def.Endpoint != nil {
endpointCount++
if len(def.Endpoint.Params) > 0 {
fmt.Printf(" ✓ Endpoint '%s %s' has %d params (block syntax working)\n", def.Endpoint.Method, def.Endpoint.Path, len(def.Endpoint.Params))
}
if endpointCount >= 2 { // Just show first couple
break
}
}
}
// Check pages have content in blocks
pageCount := 0
for _, def := range ast.Definitions {
if def.Page != nil {
pageCount++
totalContent := len(def.Page.Meta) + len(def.Page.Elements)
if totalContent > 0 {
fmt.Printf(" ✓ Page '%s' has %d content items (block syntax working)\n", def.Page.Name, totalContent)
}
if pageCount >= 2 { // Just show first couple
break
}
}
}
// Check for nested sections (complex structures)
var totalSections, nestedSections int
for _, def := range ast.Definitions {
if def.Page != nil {
for _, element := range def.Page.Elements {
if element.Section != nil {
totalSections++
nestedSections += countNestedSections(*element.Section)
}
}
}
}
if totalSections > 0 {
fmt.Printf(" ✓ Found %d sections with %d nested levels (recursive parsing working)\n", totalSections, nestedSections)
}
fmt.Printf("\n🎯 Block delimiter syntax is working correctly!\n")
fmt.Printf(" All constructs (server, entity, endpoint, page, section, component) now use { } blocks\n")
fmt.Printf(" No more ambiguous whitespace-dependent parsing\n")
fmt.Printf(" Language is now unambiguous and consistent\n")
}
// Helper function to count nested sections recursively
func countNestedSections(section lang.Section) int {
count := 0
for _, element := range section.Elements {
if element.Section != nil {
count++
count += countNestedSections(*element.Section)
}
if element.Component != nil {
for _, compElement := range element.Component.Elements {
if compElement.Section != nil {
count++
count += countNestedSections(*compElement.Section)
}
}
}
}
return count
}

View File

@ -0,0 +1,294 @@
// Enhanced Masonry DSL example demonstrating simplified unified structure
// Server configuration
server MyApp {
host "localhost"
port 8080
}
// Entity definitions with various field types and relationships
entity User desc "User account management" {
id: uuid required unique
email: string required validate email validate min_length "5"
name: string default "Anonymous"
created_at: timestamp default "now()"
profile_id: uuid relates to Profile as one via "user_id"
}
entity Profile desc "User profile information" {
id: uuid required unique
user_id: uuid required relates to User as one
bio: text validate max_length "500"
avatar_url: string validate url
updated_at: timestamp
posts: uuid relates to Post as many
}
entity Post desc "Blog posts" {
id: uuid required unique
title: string required validate min_length "1" validate max_length "200"
content: text required
author_id: uuid required relates to User as one
published: boolean default "false"
created_at: timestamp default "now()"
tags: uuid relates to Tag as many through "post_tags"
}
entity Tag desc "Content tags" {
id: uuid required unique
name: string required unique validate min_length "1" validate max_length "50"
slug: string required unique indexed
created_at: timestamp default "now()"
}
// API Endpoints with different HTTP methods and parameter sources
endpoint GET "/users" for User desc "List users" auth {
param page: int from query
param limit: int required from query
returns list as "json" fields [id, email, name]
}
endpoint POST "/users" for User desc "Create user" {
param user_data: object required from body
returns object as "json" fields [id, email, name]
}
endpoint PUT "/users/{id}" for User desc "Update user" {
param id: uuid required from path
param user_data: object required from body
returns object
custom "update_user_logic"
}
endpoint DELETE "/users/{id}" for User desc "Delete user" auth {
param id: uuid required from path
returns object
}
endpoint GET "/posts" for Post desc "List posts" {
param author_id: uuid from query
param published: boolean from query
param page: int from query
returns list as "json" fields [id, title, author_id, published]
}
endpoint POST "/posts" for Post desc "Create post" auth {
param post_data: object required from body
returns object fields [id, title, content, author_id]
}
// Enhanced User Management page with unified section layout
page UserManagement at "/admin/users" layout AdminLayout title "User Management" auth {
meta description "Manage system users"
meta keywords "users, admin, management"
section main type container class "grid grid-cols-3 gap-4" {
section sidebar class "col-span-1" {
component UserStats for User {
data from "/users/stats"
}
}
section content class "col-span-2" {
component UserTable for User {
fields [email, name, role, created_at]
actions [edit, delete, view]
data from "/users"
}
section editPanel type panel trigger "edit" position "slide-right" for User {
component UserForm for User {
field email type text label "Email" required
field name type text label "Name" required
field role type select options ["admin", "user"]
button save label "Save User" style "primary" via "/users/{id}"
button cancel label "Cancel" style "secondary"
}
}
}
}
}
// Enhanced Form component with detailed field configurations
page UserFormPage at "/admin/users/new" layout AdminLayout title "Create User" auth {
component Form for User {
field email type text label "Email Address" placeholder "Enter your email" required validate email
field name type text label "Full Name" placeholder "Enter your full name" required
field role type select label "User Role" options ["admin", "user", "moderator"] default "user"
field avatar type file label "Profile Picture" accept "image/*"
field bio type textarea label "Biography" placeholder "Tell us about yourself" rows 4
when role equals "admin" {
component AdminPermissions {
field permissions type multiselect label "Permissions" {
options ["users.manage", "posts.manage", "system.config"]
}
}
}
section actions {
component ActionButtons {
button save label "Save User" style "primary" loading "Saving..." via "/users"
button cancel label "Cancel" style "secondary"
}
}
}
}
// Dashboard with tabbed interface using unified sections
page Dashboard at "/dashboard" layout MainLayout title "Dashboard" {
section tabs type container {
section overview type tab label "Overview" active {
component StatsCards
component RecentActivity
}
section users type tab label "Users" {
component UserTable for User {
data from "/users"
}
}
section posts type tab label "Posts" {
component PostTable for Post {
data from "/posts"
}
}
}
section createUserModal type modal trigger "create-user" {
component UserForm for User {
field email type text label "Email" required
field name type text label "Name" required
button save label "Create" via "/users"
button cancel label "Cancel"
}
}
}
// Post Management with master-detail layout using unified sections
page PostManagement at "/admin/posts" layout AdminLayout title "Post Management" auth {
section master type master {
component PostTable for Post {
field title type text label "Title" sortable
field author type relation label "Author" display "name" relates to User
field status type badge label "Status"
}
}
section detail type detail trigger "edit" {
component PostForm for Post {
section basic class "mb-4" {
component BasicFields {
field title type text label "Post Title" required
field content type richtext label "Content" required
}
}
section metadata class "grid grid-cols-2 gap-4" {
component MetadataFields {
field author_id type autocomplete label "Author" {
source "/users" display "name" value "id"
}
field published type toggle label "Published" default "false"
field tags type multiselect label "Tags" {
source "/tags" display "name" value "id"
}
}
}
}
}
}
// Simple table component with smart defaults
page SimpleUserList at "/users" layout MainLayout title "Users" {
component SimpleTable for User {
fields [email, name, created_at]
actions [edit, delete]
data from "/users"
}
}
// Detailed table with simplified component attributes
page DetailedUserList at "/admin/users/detailed" layout AdminLayout title "Detailed User Management" auth {
component DetailedTable for User {
data from "/users"
pagination size 20
field email type text label "Email Address"
field name type text label "Full Name"
}
}
// Complex nested sections example
page ComplexLayout at "/complex" layout MainLayout title "Complex Layout" {
section mainContainer type container class "flex h-screen" {
section sidebar type container class "w-64 bg-gray-100" {
section navigation {
component NavMenu
}
section userInfo type panel trigger "profile" position "bottom" {
component UserProfile
}
}
section content type container class "flex-1" {
section header class "h-16 border-b" {
component PageHeader
}
section body class "flex-1 p-4" {
section tabs type container {
section overview type tab label "Overview" active {
section metrics class "grid grid-cols-3 gap-4" {
component MetricCard
component MetricCard
component MetricCard
}
}
section details type tab label "Details" {
component DetailView
}
}
}
}
}
}
// Conditional rendering with sections and components
page ConditionalForm at "/conditional" layout MainLayout title "Conditional Form" {
component UserForm for User {
field email type text label "Email" required
field role type select options ["admin", "user", "moderator"]
when role equals "admin" {
section adminSection class "border-l-4 border-red-500 pl-4" {
component AdminPermissions {
field permissions type multiselect label "Admin Permissions" {
options ["users.manage", "posts.manage", "system.config"]
}
}
component AdminSettings {
field max_users type number label "Max Users"
}
}
}
when role equals "moderator" {
component ModeratorSettings {
field moderation_level type select label "Moderation Level" {
options ["basic", "advanced", "full"]
}
}
}
section actions {
component ActionButtons {
button save label "Save User" style "primary" loading "Saving..."
button cancel label "Cancel" style "secondary"
}
}
}
}

202
examples/lang/readme.md Normal file
View 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

View 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"
}
}
}
}

View 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"
}
}
}

View 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

View 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);
}
}

View 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}`);
},
};

View 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;

View 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>

View 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>
);

View 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"

View 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"
}
}

View 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>
);
}

View 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>
);
}

View 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}}
}

View 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>
);
}

View File

@ -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>
);
}

View 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"
]
}

View File

@ -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}}

View 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

5
go.mod
View File

@ -3,13 +3,16 @@ module masonry
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/alecthomas/participle/v2 v2.1.4 // indirect
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
)

20
go.sum
View File

@ -1,12 +1,32 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/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=

View 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)
}
}

View 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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
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(&section, 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
}

View 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
}

View 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()
}

View 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 := &section.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 := &section.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]
}

View 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
View 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")
}
}

View File

@ -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:"@@*"`
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:"@@*"`
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')"`
@ -68,12 +100,12 @@ type Endpoint struct {
Entity *string `parser:"('for' @Ident)?"`
Description *string `parser:"('desc' @String)?"`
Auth bool `parser:"@'auth'?"`
Params []EndpointParam `parser:"@@*"`
Params []EndpointParam `parser:"('{' @@*"` // Block-delimited parameters
Response *ResponseSpec `parser:"@@?"`
CustomLogic *string `parser:"('custom' @String)?"`
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,89 +113,194 @@ 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)* ']')?"`
}
// Page definitions for frontend with clean syntax
// 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:"@@*"`
Components []Component `parser:"@@*"`
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"`
}
// Component definitions with endpoint references
// 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'))?"`
Class *string `parser:"('class' @String)?"`
Label *string `parser:"('label' @String)?"` // for tabs
Active bool `parser:"@'active'?"` // for tabs
Trigger *string `parser:"('trigger' @String)?"` // for panels/modals/detail
Position *string `parser:"('position' @String)?"` // for panels
Entity *string `parser:"('for' @Ident)?"` // for panels
Elements []SectionElement `parser:"('{' @@* '}')?"` // Block-delimited elements for unambiguous nesting
}
// SectionElement New unified element type for sections
type SectionElement struct {
Attribute *SectionAttribute `parser:"@@"`
Component *Component `parser:"| @@"`
Section *Section `parser:"| @@"`
When *WhenCondition `parser:"| @@"`
}
// SectionAttribute Flexible section attributes (replaces complex config types)
type SectionAttribute struct {
DataSource *string `parser:"('data' 'from' @String)"`
Style *string `parser:"| ('style' @String)"`
Classes *string `parser:"| ('classes' @String)"`
Size *int `parser:"| ('size' @Int)"` // for pagination, etc.
Theme *string `parser:"| ('theme' @String)"`
}
// Component Simplified Component with unified attributes - reordered for better parsing
type Component struct {
Type string `parser:"'component' @Ident"`
Entity *string `parser:"('for' @Ident)?"`
Config []ComponentAttr `parser:"@@*"`
Type string `parser:"'component' @Ident"`
Entity *string `parser:"('for' @Ident)?"`
Elements []ComponentElement `parser:"('{' @@* '}')?"` // Parse everything inside the block
}
// Component attributes and configurations
// 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:"| @@"`
Section *Section `parser:"| @@"` // Sections can be nested in components
Button *ComponentButton `parser:"| @@"`
When *WhenCondition `parser:"| @@"`
}
// ComponentAttr Simplified component attributes using key-value pattern - reordered for precedence
type ComponentAttr struct {
Fields *ComponentFields `parser:"@@"`
Actions *ComponentActions `parser:"| @@"`
DataSource *ComponentDataSource `parser:"| @@"`
Style *ComponentStyle `parser:"| @@"`
Pagination *ComponentPagination `parser:"| @@"`
Filters *ComponentFilters `parser:"| @@"`
Validation bool `parser:"| @'validate'"`
DataSource *string `parser:"('data' 'from' @String)"`
Fields []string `parser:"| ('fields' '[' @Ident (',' @Ident)* ']')"`
Actions []string `parser:"| ('actions' '[' @Ident (',' @Ident)* ']')"`
Style *string `parser:"| ('style' @String)"`
Classes *string `parser:"| ('classes' @String)"`
PageSize *int `parser:"| ('pagination' 'size' @Int)"`
Validate bool `parser:"| @'validate'"`
}
// Component field specification
type ComponentFields struct {
Fields []string `parser:"'fields' '[' @Ident (',' @Ident)* ']'"`
// 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
}
// Component actions (can reference endpoints)
type ComponentActions struct {
Actions []ComponentAction `parser:"'actions' '[' @@ (',' @@)* ']'"`
// ComponentFieldAttribute Flexible field attribute system
type ComponentFieldAttribute struct {
Label *string `parser:"('label' @String)"`
Placeholder *string `parser:"| ('placeholder' @String)"`
Required bool `parser:"| @'required'"`
Sortable bool `parser:"| @'sortable'"`
Searchable bool `parser:"| @'searchable'"`
Thumbnail bool `parser:"| @'thumbnail'"`
Default *string `parser:"| ('default' @String)"`
Options []string `parser:"| ('options' '[' @String (',' @String)* ']')"`
Accept *string `parser:"| ('accept' @String)"`
Rows *int `parser:"| ('rows' @Int)"`
Format *string `parser:"| ('format' @String)"`
Size *string `parser:"| ('size' @String)"`
Display *string `parser:"| ('display' @String)"`
Value *string `parser:"| ('value' @String)"`
Source *string `parser:"| ('source' @String)"`
Relates *FieldRelation `parser:"| @@"`
Validation *ComponentValidation `parser:"| @@"`
}
type ComponentAction struct {
Name string `parser:"@Ident"`
Endpoint *string `parser:"('via' @String)?"`
// FieldRelation Field relationship for autocomplete and select fields
type FieldRelation struct {
Type string `parser:"'relates' 'to' @Ident"`
}
// Data source configuration (can reference endpoints)
type ComponentDataSource struct {
Endpoint string `parser:"'data' 'from' @String"`
// ComponentValidation Component validation
type ComponentValidation struct {
Type string `parser:"'validate' @Ident"`
Value *string `parser:"@String?"`
}
// Component styling
type ComponentStyle struct {
Theme *string `parser:"'style' @Ident"`
Classes []string `parser:"('classes' '[' @String (',' @String)* ']')?"`
// 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')"`
Value string `parser:"@String"`
Fields []ComponentField `parser:"('{' @@*"`
Sections []Section `parser:"@@*"` // Can contain sections
Components []Component `parser:"@@*"` // Can contain components
Buttons []ComponentButton `parser:"@@* '}')?"` // Block-delimited for unambiguous nesting
}
// Pagination configuration
type ComponentPagination struct {
PageSize *int `parser:"'pagination' ('size' @Int)?"`
// ComponentButton Simplified button with flexible attribute ordering
type ComponentButton struct {
Name string `parser:"'button' @Ident"`
Label string `parser:"'label' @String"`
Attributes []ComponentButtonAttr `parser:"@@*"`
}
// Filter specifications
type ComponentFilters struct {
Filters []ComponentFilter `parser:"'filters' '[' @@ (',' @@)* ']'"`
// ComponentButtonAttr Flexible button attribute system - each attribute is a separate alternative
type ComponentButtonAttr struct {
Style *ComponentButtonStyle `parser:"@@"`
Icon *ComponentButtonIcon `parser:"| @@"`
Loading *ComponentButtonLoading `parser:"| @@"`
Disabled *ComponentButtonDisabled `parser:"| @@"`
Confirm *ComponentButtonConfirm `parser:"| @@"`
Target *ComponentButtonTarget `parser:"| @@"`
Position *ComponentButtonPosition `parser:"| @@"`
Via *ComponentButtonVia `parser:"| @@"`
}
type ComponentFilter struct {
Field string `parser:"@Ident"`
Type string `parser:"'as' @('text' | 'select' | 'date' | 'number')"`
Label *string `parser:"('label' @String)?"`
// ComponentButtonStyle Individual button attribute types
type ComponentButtonStyle struct {
Value string `parser:"'style' @String"`
}
type ComponentButtonIcon struct {
Value string `parser:"'icon' @String"`
}
type ComponentButtonLoading struct {
Value string `parser:"'loading' @String"`
}
type ComponentButtonDisabled struct {
Value string `parser:"'disabled' 'when' @Ident"`
}
type ComponentButtonConfirm struct {
Value string `parser:"'confirm' @String"`
}
type ComponentButtonTarget struct {
Value string `parser:"'target' @Ident"`
}
type ComponentButtonPosition struct {
Value string `parser:"'position' @String"`
}
type ComponentButtonVia struct {
Value string `parser:"'via' @String"`
}
func ParseInput(input string) (AST, error) {

File diff suppressed because it is too large Load Diff

131
lang/parser_entity_test.go Normal file
View File

@ -0,0 +1,131 @@
package lang
import (
"testing"
)
func TestParseEntityDefinitions(t *testing.T) {
tests := []struct {
name string
input string
want AST
wantErr bool
}{
{
name: "entity with enhanced fields and relationships",
input: `entity User desc "User management" {
id: uuid required unique
email: string required validate email validate min_length "5"
name: string default "Anonymous"
profile_id: uuid relates to Profile as one via "user_id"
}`,
want: AST{
Definitions: []Definition{
{
Entity: &Entity{
Name: "User",
Description: stringPtr("User management"),
Fields: []Field{
{
Name: "id",
Type: "uuid",
Required: true,
Unique: true,
},
{
Name: "email",
Type: "string",
Required: true,
Validations: []Validation{
{Type: "email"},
{Type: "min_length", Value: stringPtr("5")},
},
},
{
Name: "name",
Type: "string",
Default: stringPtr("Anonymous"),
},
{
Name: "profile_id",
Type: "uuid",
Relationship: &Relationship{
Type: "Profile",
Cardinality: "one",
ForeignKey: stringPtr("user_id"),
},
},
},
},
},
},
},
wantErr: false,
},
{
name: "simple entity with basic fields",
input: `entity Product {
id: uuid required unique
name: string required
price: decimal default "0.00"
}`,
want: AST{
Definitions: []Definition{
{
Entity: &Entity{
Name: "Product",
Fields: []Field{
{
Name: "id",
Type: "uuid",
Required: true,
Unique: true,
},
{
Name: "name",
Type: "string",
Required: true,
},
{
Name: "price",
Type: "decimal",
Default: stringPtr("0.00"),
},
},
},
},
},
},
wantErr: false,
},
{
name: "entity without fields block",
input: `entity SimpleEntity`,
want: AST{
Definitions: []Definition{
{
Entity: &Entity{
Name: "SimpleEntity",
Fields: []Field{},
},
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInput(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && !astEqual(got, tt.want) {
t.Errorf("ParseInput() mismatch.\nGot: %+v\nWant: %+v", got, tt.want)
}
})
}
}

342
lang/parser_env_test.go Normal file
View 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")
}
}
}

132
lang/parser_server_test.go Normal file
View File

@ -0,0 +1,132 @@
package lang
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
input string
want AST
wantErr bool
}{
{
name: "simple server definition with block delimiters",
input: `server MyApp {
host "localhost"
port 8080
}`,
want: AST{
Definitions: []Definition{
{
Server: &Server{
Name: "MyApp",
Settings: []ServerSetting{
{Host: literalConfigValue("localhost")},
{Port: literalIntValue(8080)},
},
},
},
},
},
wantErr: false,
},
{
name: "server with only host setting",
input: `server WebApp {
host "0.0.0.0"
}`,
want: AST{
Definitions: []Definition{
{
Server: &Server{
Name: "WebApp",
Settings: []ServerSetting{
{Host: literalConfigValue("0.0.0.0")},
},
},
},
},
},
wantErr: false,
},
{
name: "server with only port setting",
input: `server APIServer {
port 3000
}`,
want: AST{
Definitions: []Definition{
{
Server: &Server{
Name: "APIServer",
Settings: []ServerSetting{
{Port: literalIntValue(3000)},
},
},
},
},
},
wantErr: false,
},
{
name: "server without settings block",
input: `server SimpleServer`,
want: AST{
Definitions: []Definition{
{
Server: &Server{
Name: "SimpleServer",
Settings: []ServerSetting{},
},
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInput(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && !astEqual(got, tt.want) {
t.Errorf("ParseInput() mismatch.\nGot: %+v\nWant: %+v", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,689 @@
package lang
import (
"testing"
)
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 {
component form for User {
field status type select options ["active", "inactive", "pending"]
when status equals "active" {
field last_login type datetime
field permissions type multiselect
button deactivate label "Deactivate User" style "warning"
}
when status not_equals "active" {
field reason type textarea placeholder "Reason for status"
button activate label "Activate User" style "success"
}
when status contains "pending" {
field approval_date type date
button approve label "Approve" style "primary"
button reject label "Reject" style "danger"
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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: "not_equals",
Value: "active",
Fields: []ComponentField{
{
Name: "reason",
Type: "textarea",
Attributes: []ComponentFieldAttribute{
{Placeholder: stringPtr("Reason for status")},
},
},
},
Buttons: []ComponentButton{
{
Name: "activate",
Label: "Activate User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "success"}},
},
},
},
},
},
{
When: &WhenCondition{
Field: "status",
Operator: "contains",
Value: "pending",
Fields: []ComponentField{
{Name: "approval_date", Type: "date"},
},
Buttons: []ComponentButton{
{
Name: "approve",
Label: "Approve",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
},
},
{
Name: "reject",
Label: "Reject",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "field attributes with all possible options",
input: `page Test at "/test" layout main {
component form for Product {
field name type text {
label "Product Name"
placeholder "Enter product name"
required
default "New Product"
validate min_length "3"
size "large"
display "block"
}
field price type number {
label "Price ($)"
format "currency"
validate min "0"
validate max "10000"
}
field category type autocomplete {
label "Category"
placeholder "Start typing..."
relates to Category
searchable
source "categories/search"
}
field tags type multiselect {
label "Tags"
options ["electronics", "clothing", "books", "home"]
source "tags/popular"
}
field description type richtext {
label "Description"
rows 10
placeholder "Describe your product..."
}
field thumbnail type image {
label "Product Image"
accept "image/jpeg,image/png"
thumbnail
}
field featured type checkbox {
label "Featured Product"
default "false"
value "true"
}
field availability type select {
label "Availability"
options ["in_stock", "out_of_stock", "pre_order"]
default "in_stock"
sortable
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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: "category",
Type: "autocomplete",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Category")},
{Placeholder: stringPtr("Start typing...")},
{Relates: &FieldRelation{Type: "Category"}},
{Searchable: true},
{Source: stringPtr("categories/search")},
},
},
},
{
Field: &ComponentField{
Name: "tags",
Type: "multiselect",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Tags")},
{Options: []string{"electronics", "clothing", "books", "home"}},
{Source: stringPtr("tags/popular")},
},
},
},
{
Field: &ComponentField{
Name: "description",
Type: "richtext",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Description")},
{Rows: intPtr(10)},
{Placeholder: stringPtr("Describe your product...")},
},
},
},
{
Field: &ComponentField{
Name: "thumbnail",
Type: "image",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Product Image")},
{Accept: stringPtr("image/jpeg,image/png")},
{Thumbnail: true},
},
},
},
{
Field: &ComponentField{
Name: "featured",
Type: "checkbox",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Featured Product")},
{Default: stringPtr("false")},
{Value: stringPtr("true")},
},
},
},
{
Field: &ComponentField{
Name: "availability",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Availability")},
{Options: []string{"in_stock", "out_of_stock", "pre_order"}},
{Default: stringPtr("in_stock")},
{Sortable: true},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "complex button configurations",
input: `page Test at "/test" layout main {
component form for Order {
field status type select options ["draft", "submitted", "approved"]
button save label "Save Draft" style "secondary" icon "save" position "left"
button submit label "Submit Order" style "primary" icon "send" loading "Submitting..." confirm "Submit this order?"
button approve label "Approve" style "success" loading "Approving..." disabled when status confirm "Approve this order?" target approval_modal via "api/orders/approve"
button reject label "Reject" style "danger" icon "x" confirm "Are you sure you want to reject this order?"
button print label "Print" style "outline" icon "printer" position "right"
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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: "submit",
Label: "Submit Order",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
{Icon: &ComponentButtonIcon{Value: "send"}},
{Loading: &ComponentButtonLoading{Value: "Submitting..."}},
{Confirm: &ComponentButtonConfirm{Value: "Submit this order?"}},
},
},
},
{
Button: &ComponentButton{
Name: "approve",
Label: "Approve",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "success"}},
{Loading: &ComponentButtonLoading{Value: "Approving..."}},
{Disabled: &ComponentButtonDisabled{Value: "status"}},
{Confirm: &ComponentButtonConfirm{Value: "Approve this order?"}},
{Target: &ComponentButtonTarget{Value: "approval_modal"}},
{Via: &ComponentButtonVia{Value: "api/orders/approve"}},
},
},
},
{
Button: &ComponentButton{
Name: "reject",
Label: "Reject",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
{Icon: &ComponentButtonIcon{Value: "x"}},
{Confirm: &ComponentButtonConfirm{Value: "Are you sure you want to reject this order?"}},
},
},
},
{
Button: &ComponentButton{
Name: "print",
Label: "Print",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "outline"}},
{Icon: &ComponentButtonIcon{Value: "printer"}},
{Position: &ComponentButtonPosition{Value: "right"}},
},
},
},
},
},
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInput(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !astEqual(got, tt.want) {
t.Errorf("ParseInput() got = %v, want = %v", got, tt.want)
}
})
}
}
func TestParseFieldValidationTypes(t *testing.T) {
validationTypes := []struct {
validation string
hasValue bool
}{
{"email", false},
{"required", false},
{"min_length", true},
{"max_length", true},
{"min", true},
{"max", true},
{"pattern", true},
{"numeric", false},
{"alpha", false},
{"alphanumeric", false},
{"url", false},
{"date", false},
{"datetime", false},
{"time", false},
{"phone", false},
{"postal_code", false},
{"credit_card", false},
}
for _, vt := range validationTypes {
t.Run("validation_"+vt.validation, func(t *testing.T) {
var input string
if vt.hasValue {
input = `page Test at "/test" layout main {
component form {
field test_field type text validate ` + vt.validation + ` "test_value"
}
}`
} else {
input = `page Test at "/test" layout main {
component form {
field test_field type text validate ` + vt.validation + `
}
}`
}
got, err := ParseInput(input)
if err != nil {
t.Errorf("ParseInput() failed for validation %s: %v", vt.validation, err)
return
}
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
t.Errorf("ParseInput() failed to parse page for validation %s", vt.validation)
return
}
page := got.Definitions[0].Page
if len(page.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.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
}
attr := element.Field.Attributes[0]
if attr.Validation == nil || attr.Validation.Type != vt.validation {
t.Errorf("ParseInput() validation type mismatch: got %v, want %s", attr.Validation, vt.validation)
}
if vt.hasValue && (attr.Validation.Value == nil || *attr.Validation.Value != "test_value") {
t.Errorf("ParseInput() validation value mismatch for %s", vt.validation)
}
})
}
}
func TestParseConditionalOperators(t *testing.T) {
operators := []string{"equals", "not_equals", "contains"}
for _, op := range operators {
t.Run("operator_"+op, func(t *testing.T) {
input := `page Test at "/test" layout main {
component form {
field test_field type text
when test_field ` + op + ` "test_value" {
field conditional_field type text
}
}
}`
got, err := ParseInput(input)
if err != nil {
t.Errorf("ParseInput() failed for operator %s: %v", op, err)
return
}
// Verify the when condition was parsed correctly
page := got.Definitions[0].Page
component := page.Elements[0].Component
whenElement := component.Elements[1].When
if whenElement == nil || whenElement.Operator != op {
t.Errorf("ParseInput() operator mismatch: got %v, want %s", whenElement, op)
}
})
}
}
func TestParseAdvancedUIErrors(t *testing.T) {
tests := []struct {
name string
input string
}{
{
name: "invalid conditional operator",
input: `page Test at "/test" layout main {
component form {
when field invalid_operator "value" {
field test type text
}
}
}`,
},
{
name: "missing field attribute block closure",
input: `page Test at "/test" layout main {
component form {
field test type text {
label "Test"
required
}
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseInput(tt.input)
if err == nil {
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
}
})
}
}

View File

@ -0,0 +1,566 @@
package lang
import (
"testing"
)
func TestParseComponentDefinitions(t *testing.T) {
tests := []struct {
name string
input string
want AST
wantErr bool
}{
{
name: "basic component with entity",
input: `page Test at "/test" layout main {
component table for User
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("User"),
},
},
},
},
},
},
},
},
{
name: "form component with fields",
input: `page Test at "/test" layout main {
component form for User {
field name type text label "Full Name" placeholder "Enter your name" required
field email type email label "Email Address" required
field bio type textarea rows 5 placeholder "Tell us about yourself"
field avatar type file accept "image/*"
field role type select options ["admin", "user", "guest"] default "user"
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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: "bio",
Type: "textarea",
Attributes: []ComponentFieldAttribute{
{Rows: intPtr(5)},
{Placeholder: stringPtr("Tell us about yourself")},
},
},
},
{
Field: &ComponentField{
Name: "avatar",
Type: "file",
Attributes: []ComponentFieldAttribute{
{Accept: stringPtr("image/*")},
},
},
},
{
Field: &ComponentField{
Name: "role",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"admin", "user", "guest"}},
{Default: stringPtr("user")},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "component with field attributes and validation",
input: `page Test at "/test" layout main {
component form for Product {
field name type text required validate min_length "3"
field price type number format "currency" validate min "0"
field category type autocomplete relates to Category
field tags type multiselect source "tags/popular"
field description type richtext
field featured type checkbox default "false"
field thumbnail type image thumbnail
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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: "category",
Type: "autocomplete",
Attributes: []ComponentFieldAttribute{
{Relates: &FieldRelation{Type: "Category"}},
},
},
},
{
Field: &ComponentField{
Name: "tags",
Type: "multiselect",
Attributes: []ComponentFieldAttribute{
{Source: stringPtr("tags/popular")},
},
},
},
{
Field: &ComponentField{
Name: "description",
Type: "richtext",
},
},
{
Field: &ComponentField{
Name: "featured",
Type: "checkbox",
Attributes: []ComponentFieldAttribute{
{Default: stringPtr("false")},
},
},
},
{
Field: &ComponentField{
Name: "thumbnail",
Type: "image",
Attributes: []ComponentFieldAttribute{
{Thumbnail: true},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "component with buttons",
input: `page Test at "/test" layout main {
component form for User {
field name type text
button save label "Save User" style "primary" icon "save"
button cancel label "Cancel" style "secondary"
button delete label "Delete" style "danger" confirm "Are you sure?" disabled when is_protected
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
},
},
{
Button: &ComponentButton{
Name: "save",
Label: "Save User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
{Icon: &ComponentButtonIcon{Value: "save"}},
},
},
},
{
Button: &ComponentButton{
Name: "cancel",
Label: "Cancel",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
},
},
},
{
Button: &ComponentButton{
Name: "delete",
Label: "Delete",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
{Confirm: &ComponentButtonConfirm{Value: "Are you sure?"}},
{Disabled: &ComponentButtonDisabled{Value: "is_protected"}},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "component with conditional fields",
input: `page Test at "/test" layout main {
component form for User {
field account_type type select options ["personal", "business"]
when account_type equals "business" {
field company_name type text required
field tax_id type text
button verify_business label "Verify Business"
}
when account_type equals "personal" {
field date_of_birth type date
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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},
},
},
{
Name: "tax_id",
Type: "text",
},
},
Buttons: []ComponentButton{
{
Name: "verify_business",
Label: "Verify Business",
},
},
},
},
{
When: &WhenCondition{
Field: "account_type",
Operator: "equals",
Value: "personal",
Fields: []ComponentField{
{
Name: "date_of_birth",
Type: "date",
},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "component with nested sections",
input: `page Test at "/test" layout main {
component dashboard {
section stats type container class "stats-grid" {
component metric {
field total_users type display value "1,234"
field revenue type display format "currency" value "45,678"
}
}
section charts type container {
component chart for Analytics {
data from "analytics/monthly"
}
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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")},
},
},
},
},
},
},
},
},
},
{
Section: &Section{
Name: "charts",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "chart",
Entity: stringPtr("Analytics"),
Elements: []ComponentElement{
{
Attribute: &ComponentAttr{
DataSource: stringPtr("analytics/monthly"),
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInput(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !astEqual(got, tt.want) {
t.Errorf("ParseInput() got = %+v, want %+v", got, tt.want)
}
})
}
}
func TestParseComponentFieldTypes(t *testing.T) {
fieldTypes := []string{
"text", "email", "password", "number", "date", "datetime", "time",
"textarea", "richtext", "select", "multiselect", "checkbox", "radio",
"file", "image", "autocomplete", "range", "color", "url", "tel",
"hidden", "display", "json", "code",
}
for _, fieldType := range fieldTypes {
t.Run("field_type_"+fieldType, func(t *testing.T) {
input := `page Test at "/test" layout main {
component form {
field test_field type ` + fieldType + `
}
}`
got, err := ParseInput(input)
if err != nil {
t.Errorf("ParseInput() failed for field type %s: %v", fieldType, err)
return
}
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
t.Errorf("ParseInput() failed to parse page for field type %s", fieldType)
return
}
page := got.Definitions[0].Page
if len(page.Elements) != 1 {
t.Errorf("ParseInput() failed to parse component for field type %s", fieldType)
return
}
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)
}
})
}
}
func TestParseComponentErrors(t *testing.T) {
tests := []struct {
name string
input string
}{
{
name: "missing component type",
input: `page Test at "/test" layout main {
component
}`,
},
{
name: "invalid field syntax",
input: `page Test at "/test" layout main {
component form {
field name
}
}`,
},
{
name: "invalid button syntax",
input: `page Test at "/test" layout main {
component form {
button
}
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseInput(tt.input)
if err == nil {
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
}
})
}
}

419
lang/parser_ui_page_test.go Normal file
View File

@ -0,0 +1,419 @@
package lang
import (
"testing"
)
func TestParsePageDefinitions(t *testing.T) {
tests := []struct {
name string
input string
want AST
wantErr bool
}{
{
name: "basic page definition",
input: `page Home at "/" layout main`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Home",
Path: "/",
Layout: "main",
},
},
},
},
},
{
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: "Settings",
Path: "/settings",
Layout: "main",
Title: stringPtr("User Settings"),
Description: stringPtr("Manage your account settings"),
Auth: true,
},
},
},
},
},
{
name: "page with meta tags",
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: "Settings",
Path: "/settings",
Layout: "main",
Meta: []MetaTag{
{Name: "description", Content: "Settings page description"},
{Name: "keywords", Content: "settings, user, account"},
{Name: "author", Content: "My App"},
},
},
},
},
},
},
{
name: "page with sections",
input: `page Settings at "/settings" layout main {
section tabs type tab {
section profile label "Profile" active {
component form for User
}
section security label "Security" {
component form for Security
}
section notifications label "Notifications" {
component toggle for NotificationSettings
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Settings",
Path: "/settings",
Layout: "main",
Elements: []PageElement{
{
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("Security"),
},
},
},
},
},
{
Section: &Section{
Name: "notifications",
Label: stringPtr("Notifications"),
Elements: []SectionElement{
{
Component: &Component{
Type: "toggle",
Entity: stringPtr("NotificationSettings"),
},
},
},
},
},
},
},
},
},
},
},
},
},
},
{
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"
}
component chart for SalesData {
data from "analytics/sales"
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Dashboard",
Path: "/dashboard",
Layout: "main",
Elements: []PageElement{
{
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")},
},
},
},
},
},
},
{
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"},
},
},
},
},
},
},
},
},
{
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",
},
},
},
},
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInput(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !astEqual(got, tt.want) {
t.Errorf("ParseInput() = %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)
}
})
}
}
func TestParsePageErrors(t *testing.T) {
tests := []struct {
name string
input string
}{
{
name: "missing page name",
input: `page at "/" layout main`,
},
{
name: "missing path",
input: `page Test layout main`,
},
{
name: "missing layout",
input: `page Test at "/"`,
},
{
name: "invalid path format",
input: `page Test at /invalid layout main`,
},
{
name: "unclosed page block",
input: `page Test at "/" layout main {
section test type container
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseInput(tt.input)
if err == nil {
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
}
})
}
}

View File

@ -0,0 +1,653 @@
package lang
import (
"testing"
)
func TestParseSectionDefinitions(t *testing.T) {
tests := []struct {
name string
input string
want AST
wantErr bool
}{
{
name: "basic container section",
input: `page Test at "/test" layout main {
section main type container
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
Section: &Section{
Name: "main",
Type: stringPtr("container"),
},
},
},
},
},
},
},
},
{
name: "section with all attributes",
input: `page Test at "/test" layout main {
section sidebar type panel class "sidebar-nav" label "Navigation" trigger "toggle-sidebar" position "left" for User
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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"),
},
},
},
},
},
},
},
},
},
},
},
{
name: "tab sections with active state",
input: `page Test at "/test" layout main {
section tabs type tab {
section overview label "Overview" active
section details label "Details"
section settings label "Settings"
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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: "settings",
Label: stringPtr("Settings"),
},
},
},
},
},
},
},
},
},
},
},
{
name: "modal section with content",
input: `page Test at "/test" layout main {
section userModal type modal trigger "edit-user" {
component form for User {
field name type text required
field email type email required
button save label "Save Changes" style "primary"
button cancel label "Cancel" style "secondary"
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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},
},
},
},
{
Button: &ComponentButton{
Name: "save",
Label: "Save Changes",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
},
},
},
{
Button: &ComponentButton{
Name: "cancel",
Label: "Cancel",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "master-detail sections",
input: `page Test at "/test" layout main {
section masterDetail type master {
section userList type container {
component table for User {
fields [name, email]
}
}
section userDetail type detail trigger "user-selected" for User {
component form for User {
field name type text
field email type email
field bio type textarea
}
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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"},
},
},
},
},
},
},
},
},
{
Section: &Section{
Name: "userDetail",
Type: stringPtr("detail"),
Trigger: stringPtr("user-selected"),
Entity: stringPtr("User"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
},
},
{
Field: &ComponentField{
Name: "email",
Type: "email",
},
},
{
Field: &ComponentField{
Name: "bio",
Type: "textarea",
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "deeply nested sections",
input: `page Test at "/test" layout main {
section mainLayout type container {
section header type container class "header" {
component navbar {
field search type text placeholder "Search..."
}
}
section content type container {
section sidebar type panel position "left" {
component menu {
field navigation type list
}
}
section main type container {
section tabs type tab {
section overview label "Overview" active {
component dashboard {
field stats type metric
}
}
section reports label "Reports" {
component table for Report
}
}
}
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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...")},
},
},
},
},
},
},
},
},
},
{
Section: &Section{
Name: "content",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Section: &Section{
Name: "sidebar",
Type: stringPtr("panel"),
Position: stringPtr("left"),
Elements: []SectionElement{
{
Component: &Component{
Type: "menu",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "navigation",
Type: "list",
},
},
},
},
},
},
},
},
{
Section: &Section{
Name: "main",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Section: &Section{
Name: "tabs",
Type: stringPtr("tab"),
Elements: []SectionElement{
{
Section: &Section{
Name: "overview",
Label: stringPtr("Overview"),
Active: true,
Elements: []SectionElement{
{
Component: &Component{
Type: "dashboard",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "stats",
Type: "metric",
},
},
},
},
},
},
},
},
{
Section: &Section{
Name: "reports",
Label: stringPtr("Reports"),
Elements: []SectionElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("Report"),
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "section with conditional content",
input: `page Test at "/test" layout main {
section adminPanel type container {
when user_role equals "admin" {
section userManagement type container {
component table for User
}
section systemSettings type container {
component form for Settings
}
}
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
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"),
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInput(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !astEqual(got, tt.want) {
t.Errorf("ParseInput() got = %+v, want %+v", got, tt.want)
}
})
}
}
func TestParseSectionTypes(t *testing.T) {
sectionTypes := []string{
"container", "tab", "panel", "modal", "master", "detail",
}
for _, sectionType := range sectionTypes {
t.Run("section_type_"+sectionType, func(t *testing.T) {
input := `page Test at "/test" layout main {
section test_section type ` + sectionType + `
}`
got, err := ParseInput(input)
if err != nil {
t.Errorf("ParseInput() failed for section type %s: %v", sectionType, err)
return
}
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
t.Errorf("ParseInput() failed to parse page for section type %s", sectionType)
return
}
page := got.Definitions[0].Page
if len(page.Elements) != 1 || page.Elements[0].Section == nil {
t.Errorf("ParseInput() failed to parse section for type %s", sectionType)
return
}
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)
}
})
}
}
func TestParseSectionErrors(t *testing.T) {
tests := []struct {
name string
input string
}{
{
name: "missing section name",
input: `page Test at "/test" layout main {
section type container
}`,
},
{
name: "invalid section type",
input: `page Test at "/test" layout main {
section test type invalid_type
}`,
},
{
name: "unclosed section block",
input: `page Test at "/test" layout main {
section test type container {
component form
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseInput(tt.input)
if err == nil {
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
}
})
}
}

View File

@ -0,0 +1,59 @@
package lang
// AST and definition comparison functions for parser tests
// Custom comparison functions (simplified for the new structure)
func astEqual(got, want AST) bool {
if len(got.Definitions) != len(want.Definitions) {
return false
}
for i := range got.Definitions {
if !definitionEqual(got.Definitions[i], want.Definitions[i]) {
return false
}
}
return true
}
func definitionEqual(got, want Definition) bool {
// Server comparison
if (got.Server == nil) != (want.Server == nil) {
return false
}
if got.Server != nil && want.Server != nil {
if !serverEqual(*got.Server, *want.Server) {
return false
}
}
// Entity comparison
if (got.Entity == nil) != (want.Entity == nil) {
return false
}
if got.Entity != nil && want.Entity != nil {
if !entityEqual(*got.Entity, *want.Entity) {
return false
}
}
// Endpoint comparison
if (got.Endpoint == nil) != (want.Endpoint == nil) {
return false
}
if got.Endpoint != nil && want.Endpoint != nil {
if !endpointEqual(*got.Endpoint, *want.Endpoint) {
return false
}
}
// Page comparison (enhanced)
if (got.Page == nil) != (want.Page == nil) {
return false
}
if got.Page != nil && want.Page != nil {
return pageEqual(*got.Page, *want.Page)
}
return true
}

View File

@ -0,0 +1,46 @@
package lang
// Basic comparison utilities for parser tests
// Helper functions for creating pointers
func stringPtr(s string) *string {
return &s
}
func intPtr(i int) *int {
return &i
}
// Pointer comparison functions
func stringPtrEqual(got, want *string) bool {
if (got == nil) != (want == nil) {
return false
}
if got != nil && want != nil {
return *got == *want
}
return true
}
func intPtrEqual(got, want *int) bool {
if (got == nil) != (want == nil) {
return false
}
if got != nil && want != nil {
return *got == *want
}
return true
}
// Slice comparison functions
func stringSliceEqual(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i, s := range got {
if s != want[i] {
return false
}
}
return true
}

View File

@ -0,0 +1,69 @@
package lang
// Field and validation comparison functions for parser tests
func fieldEqual(got, want Field) bool {
if got.Name != want.Name || got.Type != want.Type {
return false
}
if got.Required != want.Required || got.Unique != want.Unique || got.Index != want.Index {
return false
}
if !stringPtrEqual(got.Default, want.Default) {
return false
}
if len(got.Validations) != len(want.Validations) {
return false
}
for i, validation := range got.Validations {
if !validationEqual(validation, want.Validations[i]) {
return false
}
}
if (got.Relationship == nil) != (want.Relationship == nil) {
return false
}
if got.Relationship != nil && want.Relationship != nil {
if !relationshipEqual(*got.Relationship, *want.Relationship) {
return false
}
}
return true
}
func validationEqual(got, want Validation) bool {
return got.Type == want.Type && stringPtrEqual(got.Value, want.Value)
}
func relationshipEqual(got, want Relationship) bool {
return got.Type == want.Type &&
got.Cardinality == want.Cardinality &&
stringPtrEqual(got.ForeignKey, want.ForeignKey) &&
stringPtrEqual(got.Through, want.Through)
}
func fieldRelationEqual(got, want *FieldRelation) bool {
if (got == nil) != (want == nil) {
return false
}
if got != nil && want != nil {
return got.Type == want.Type
}
return true
}
func componentValidationEqual(got, want *ComponentValidation) bool {
if (got == nil) != (want == nil) {
return false
}
if got != nil && want != nil {
return got.Type == want.Type && stringPtrEqual(got.Value, want.Value)
}
return true
}

View File

@ -0,0 +1,118 @@
package lang
// Server and entity comparison functions for parser tests
func serverEqual(got, want Server) bool {
if got.Name != want.Name {
return false
}
if len(got.Settings) != len(want.Settings) {
return false
}
for i, setting := range got.Settings {
if !serverSettingEqual(setting, want.Settings[i]) {
return false
}
}
return true
}
func serverSettingEqual(got, want ServerSetting) bool {
return 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 {
if got.Name != want.Name {
return false
}
if !stringPtrEqual(got.Description, want.Description) {
return false
}
if len(got.Fields) != len(want.Fields) {
return false
}
for i, field := range got.Fields {
if !fieldEqual(field, want.Fields[i]) {
return false
}
}
return true
}
func endpointEqual(got, want Endpoint) bool {
return got.Method == want.Method &&
got.Path == want.Path &&
stringPtrEqual(got.Entity, want.Entity) &&
stringPtrEqual(got.Description, want.Description) &&
got.Auth == want.Auth &&
stringPtrEqual(got.CustomLogic, want.CustomLogic)
// TODO: Add params and response comparison if needed
}

432
lang/test_ui_comparisons.go Normal file
View File

@ -0,0 +1,432 @@
package lang
// Page and UI component comparison functions for parser tests
func pageEqual(got, want Page) bool {
if got.Name != want.Name || got.Path != want.Path || got.Layout != want.Layout {
return false
}
if !stringPtrEqual(got.Title, want.Title) {
return false
}
if !stringPtrEqual(got.Description, want.Description) {
return false
}
if got.Auth != want.Auth {
return false
}
// Compare meta tags
if len(got.Meta) != len(want.Meta) {
return false
}
for i, meta := range got.Meta {
if !metaTagEqual(meta, want.Meta[i]) {
return false
}
}
// Compare elements (unified model)
if len(got.Elements) != len(want.Elements) {
return false
}
for i, element := range got.Elements {
if !pageElementEqual(element, want.Elements[i]) {
return false
}
}
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
}
func sectionEqual(got, want Section) bool {
if got.Name != want.Name {
return false
}
if !stringPtrEqual(got.Type, want.Type) {
return false
}
if !stringPtrEqual(got.Class, want.Class) {
return false
}
if !stringPtrEqual(got.Label, want.Label) {
return false
}
if got.Active != want.Active {
return false
}
if !stringPtrEqual(got.Trigger, want.Trigger) {
return false
}
if !stringPtrEqual(got.Position, want.Position) {
return false
}
if !stringPtrEqual(got.Entity, want.Entity) {
return false
}
// Extract different element types from the unified elements
gotAttributes := extractSectionAttributes(got.Elements)
gotComponents := extractSectionComponents(got.Elements)
gotSections := extractSectionSections(got.Elements)
gotWhen := extractSectionWhen(got.Elements)
wantAttributes := extractSectionAttributes(want.Elements)
wantComponents := extractSectionComponents(want.Elements)
wantSections := extractSectionSections(want.Elements)
wantWhen := extractSectionWhen(want.Elements)
// Compare attributes
if len(gotAttributes) != len(wantAttributes) {
return false
}
for i, attr := range gotAttributes {
if !sectionAttributeEqual(attr, wantAttributes[i]) {
return false
}
}
// Compare components
if len(gotComponents) != len(wantComponents) {
return false
}
for i, comp := range gotComponents {
if !componentEqual(comp, wantComponents[i]) {
return false
}
}
// Compare nested sections
if len(gotSections) != len(wantSections) {
return false
}
for i, sect := range gotSections {
if !sectionEqual(sect, wantSections[i]) {
return false
}
}
// Compare when conditions
if len(gotWhen) != len(wantWhen) {
return false
}
for i, when := range gotWhen {
if !whenConditionEqual(when, wantWhen[i]) {
return false
}
}
return true
}
// Helper functions to extract different element types from unified elements
func extractSectionAttributes(elements []SectionElement) []SectionAttribute {
var attrs []SectionAttribute
for _, elem := range elements {
if elem.Attribute != nil {
attrs = append(attrs, *elem.Attribute)
}
}
return attrs
}
func extractSectionComponents(elements []SectionElement) []Component {
var comps []Component
for _, elem := range elements {
if elem.Component != nil {
comps = append(comps, *elem.Component)
}
}
return comps
}
func extractSectionSections(elements []SectionElement) []Section {
var sects []Section
for _, elem := range elements {
if elem.Section != nil {
sects = append(sects, *elem.Section)
}
}
return sects
}
func extractSectionWhen(elements []SectionElement) []WhenCondition {
var whens []WhenCondition
for _, elem := range elements {
if elem.When != nil {
whens = append(whens, *elem.When)
}
}
return whens
}
func sectionAttributeEqual(got, want SectionAttribute) bool {
return stringPtrEqual(got.DataSource, want.DataSource) &&
stringPtrEqual(got.Style, want.Style) &&
stringPtrEqual(got.Classes, want.Classes) &&
intPtrEqual(got.Size, want.Size) &&
stringPtrEqual(got.Theme, want.Theme)
}
func componentEqual(got, want Component) bool {
if got.Type != want.Type {
return false
}
if !stringPtrEqual(got.Entity, want.Entity) {
return false
}
if len(got.Elements) != len(want.Elements) {
return false
}
for i, elem := range got.Elements {
if !componentElementEqual(elem, want.Elements[i]) {
return false
}
}
return true
}
func componentElementEqual(got, want ComponentElement) bool {
// Compare attributes
if (got.Attribute == nil) != (want.Attribute == nil) {
return false
}
if got.Attribute != nil && want.Attribute != nil {
if !componentAttrEqual(*got.Attribute, *want.Attribute) {
return false
}
}
// Compare fields
if (got.Field == nil) != (want.Field == nil) {
return false
}
if got.Field != nil && want.Field != nil {
if !componentFieldEqual(*got.Field, *want.Field) {
return false
}
}
// Compare sections
if (got.Section == nil) != (want.Section == nil) {
return false
}
if got.Section != nil && want.Section != nil {
if !sectionEqual(*got.Section, *want.Section) {
return false
}
}
// Compare buttons
if (got.Button == nil) != (want.Button == nil) {
return false
}
if got.Button != nil && want.Button != nil {
if !componentButtonEqual(*got.Button, *want.Button) {
return false
}
}
// Compare when conditions
if (got.When == nil) != (want.When == nil) {
return false
}
if got.When != nil && want.When != nil {
if !whenConditionEqual(*got.When, *want.When) {
return false
}
}
return true
}
func componentAttrEqual(got, want ComponentAttr) bool {
return stringPtrEqual(got.DataSource, want.DataSource) &&
stringSliceEqual(got.Fields, want.Fields) &&
stringSliceEqual(got.Actions, want.Actions) &&
stringPtrEqual(got.Style, want.Style) &&
stringPtrEqual(got.Classes, want.Classes) &&
intPtrEqual(got.PageSize, want.PageSize) &&
got.Validate == want.Validate
}
func componentFieldEqual(got, want ComponentField) bool {
if got.Name != want.Name || got.Type != want.Type {
return false
}
if len(got.Attributes) != len(want.Attributes) {
return false
}
for i, attr := range got.Attributes {
if !componentFieldAttributeEqual(attr, want.Attributes[i]) {
return false
}
}
return true
}
func componentFieldAttributeEqual(got, want ComponentFieldAttribute) bool {
return stringPtrEqual(got.Label, want.Label) &&
stringPtrEqual(got.Placeholder, want.Placeholder) &&
got.Required == want.Required &&
got.Sortable == want.Sortable &&
got.Searchable == want.Searchable &&
got.Thumbnail == want.Thumbnail &&
stringPtrEqual(got.Default, want.Default) &&
stringSliceEqual(got.Options, want.Options) &&
stringPtrEqual(got.Accept, want.Accept) &&
intPtrEqual(got.Rows, want.Rows) &&
stringPtrEqual(got.Format, want.Format) &&
stringPtrEqual(got.Size, want.Size) &&
stringPtrEqual(got.Display, want.Display) &&
stringPtrEqual(got.Value, want.Value) &&
stringPtrEqual(got.Source, want.Source) &&
fieldRelationEqual(got.Relates, want.Relates) &&
componentValidationEqual(got.Validation, want.Validation)
}
func componentButtonEqual(got, want ComponentButton) bool {
if got.Name != want.Name || got.Label != want.Label {
return false
}
// Extract attributes from both buttons for comparison
gotStyle, gotIcon, gotLoading, gotDisabled, gotConfirm, gotTarget, gotPosition, gotVia := extractButtonAttributesNew(got.Attributes)
wantStyle, wantIcon, wantLoading, wantDisabled, wantConfirm, wantTarget, wantPosition, wantVia := extractButtonAttributesNew(want.Attributes)
return stringPtrEqual(gotStyle, wantStyle) &&
stringPtrEqual(gotIcon, wantIcon) &&
stringPtrEqual(gotLoading, wantLoading) &&
stringPtrEqual(gotDisabled, wantDisabled) &&
stringPtrEqual(gotConfirm, wantConfirm) &&
stringPtrEqual(gotTarget, wantTarget) &&
stringPtrEqual(gotPosition, wantPosition) &&
stringPtrEqual(gotVia, wantVia)
}
// Helper function to extract button attributes from the new structure
func extractButtonAttributesNew(attrs []ComponentButtonAttr) (*string, *string, *string, *string, *string, *string, *string, *string) {
var style, icon, loading, disabled, confirm, target, position, via *string
for _, attr := range attrs {
if attr.Style != nil {
style = &attr.Style.Value
}
if attr.Icon != nil {
icon = &attr.Icon.Value
}
if attr.Loading != nil {
loading = &attr.Loading.Value
}
if attr.Disabled != nil {
disabled = &attr.Disabled.Value
}
if attr.Confirm != nil {
confirm = &attr.Confirm.Value
}
if attr.Target != nil {
target = &attr.Target.Value
}
if attr.Position != nil {
position = &attr.Position.Value
}
if attr.Via != nil {
via = &attr.Via.Value
}
}
return style, icon, loading, disabled, confirm, target, position, via
}
func whenConditionEqual(got, want WhenCondition) bool {
if got.Field != want.Field || got.Operator != want.Operator || got.Value != want.Value {
return false
}
// Compare fields
if len(got.Fields) != len(want.Fields) {
return false
}
for i, field := range got.Fields {
if !componentFieldEqual(field, want.Fields[i]) {
return false
}
}
// Compare sections
if len(got.Sections) != len(want.Sections) {
return false
}
for i, section := range got.Sections {
if !sectionEqual(section, want.Sections[i]) {
return false
}
}
// Compare components
if len(got.Components) != len(want.Components) {
return false
}
for i, component := range got.Components {
if !componentEqual(component, want.Components[i]) {
return false
}
}
// Compare buttons
if len(got.Buttons) != len(want.Buttons) {
return false
}
for i, button := range got.Buttons {
if !componentButtonEqual(button, want.Buttons[i]) {
return false
}
}
return true
}

View 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))
}

View 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 }}

View File

@ -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.
*

View 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.