Compare commits

...

14 Commits

54 changed files with 6138 additions and 948 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.

5
.gitignore vendored
View File

@ -1 +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,19 +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
serveCmd(), // New command for server interpreter
templateCmd(), // New command for template interpreter
}
app := &cli.App{

View File

@ -5,9 +5,6 @@ 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"
@ -16,6 +13,10 @@ import (
"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"
@ -572,3 +573,224 @@ func serveCmd() *cli.Command {
},
}
}
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
// }

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
}

View File

@ -96,7 +96,7 @@ func main() {
for _, def := range ast.Definitions {
if def.Page != nil {
pageCount++
totalContent := len(def.Page.Meta) + len(def.Page.Sections) + len(def.Page.Components)
totalContent := len(def.Page.Meta) + len(def.Page.Elements)
if totalContent > 0 {
fmt.Printf(" ✓ Page '%s' has %d content items (block syntax working)\n", def.Page.Name, totalContent)
}
@ -110,9 +110,11 @@ func main() {
var totalSections, nestedSections int
for _, def := range ast.Definitions {
if def.Page != nil {
totalSections += len(def.Page.Sections)
for _, section := range def.Page.Sections {
nestedSections += countNestedSections(section)
for _, element := range def.Page.Elements {
if element.Section != nil {
totalSections++
nestedSections += countNestedSections(*element.Section)
}
}
}
}

View File

@ -1,5 +1,4 @@
// Enhanced Masonry DSL example demonstrating simplified unified structure
// This shows how containers, tabs, panels, modals, and master-detail are now unified as sections
// Server configuration
server MyApp {

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

3
go.mod
View File

@ -4,12 +4,15 @@ go 1.23
require (
github.com/alecthomas/participle/v2 v2.1.4
github.com/robertkrimen/otto v0.5.1
github.com/urfave/cli/v2 v2.27.5
golang.org/x/text v0.22.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
)

14
go.sum
View File

@ -6,13 +6,27 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

@ -117,22 +117,22 @@ func (hi *HTMLInterpreter) generatePageHTML(page *lang.Page) (string, error) {
html.WriteString(" <div class=\"container\">\n")
html.WriteString(fmt.Sprintf(" <h1>%s</h1>\n", hi.escapeHTML(title)))
// Generate sections
for _, section := range page.Sections {
sectionHTML, err := hi.generateSectionHTML(&section, 2)
if err != nil {
return "", err
// 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)
}
html.WriteString(sectionHTML)
}
// Generate direct components
for _, component := range page.Components {
componentHTML, err := hi.generateComponentHTML(&component, 2)
if err != nil {
return "", err
if element.Component != nil {
componentHTML, err := hi.generateComponentHTML(element.Component, 2)
if err != nil {
return "", err
}
html.WriteString(componentHTML)
}
html.WriteString(componentHTML)
}
html.WriteString(" </div>\n")
@ -140,23 +140,9 @@ func (hi *HTMLInterpreter) generatePageHTML(page *lang.Page) (string, error) {
// JavaScript for interactivity
html.WriteString(" <script>\n")
// API Base URL configuration
apiBaseURL := "http://localhost:8080"
if hi.server != nil {
host := "localhost"
port := 8080
for _, setting := range hi.server.Settings {
if setting.Host != nil {
host = *setting.Host
}
if setting.Port != nil {
port = *setting.Port
}
}
apiBaseURL = fmt.Sprintf("http://%s:%d", host, port)
}
// Generate server configuration code that handles env vars and defaults at runtime
html.WriteString(hi.generateServerConfigJS())
html.WriteString(fmt.Sprintf(" const API_BASE_URL = '%s';\n", apiBaseURL))
html.WriteString(" \n")
html.WriteString(" // API helper functions\n")
html.WriteString(" async function apiRequest(method, endpoint, data = null) {\n")
@ -348,6 +334,86 @@ func (hi *HTMLInterpreter) generatePageHTML(page *lang.Page) (string, error) {
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
@ -626,7 +692,7 @@ func (hi *HTMLInterpreter) generateFieldHTML(field *lang.ComponentField, indent
default:
html.WriteString(fmt.Sprintf("%s <input type=\"text\" id=\"%s\" name=\"%s\" placeholder=\"%s\" value=\"%s\"%s>\n",
indentStr, field.Type, field.Name, field.Name, hi.escapeHTML(placeholder), hi.escapeHTML(defaultValue), requiredAttr))
indentStr, field.Name, field.Name, hi.escapeHTML(placeholder), hi.escapeHTML(defaultValue), requiredAttr))
}
html.WriteString(fmt.Sprintf("%s</div>\n", indentStr))

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

@ -4,6 +4,7 @@ import (
"fmt"
"go/format"
"masonry/lang"
"strconv"
"strings"
"golang.org/x/text/cases"
@ -69,6 +70,8 @@ import (
"fmt"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
"github.com/google/uuid"
@ -362,25 +365,95 @@ func (si *ServerInterpreter) generateMainFunction() string {
}
}
// Server configuration
host := "localhost"
port := 8080
// Generate server configuration code that handles env vars and defaults at runtime
code.WriteString("\n\t// Server configuration\n")
code.WriteString(si.generateServerConfigCode())
if si.server != nil {
for _, setting := range si.server.Settings {
if setting.Host != nil {
host = *setting.Host
}
if setting.Port != nil {
port = *setting.Port
}
}
}
code.WriteString(fmt.Sprintf("\n\taddr := \"%s:%d\"\n", host, port))
code.WriteString("\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:"('{' @@* '}')?"` // Block-delimited settings for consistency
}
type ServerSetting struct {
Host *string `parser:"('host' @String)"`
Port *int `parser:"| ('port' @Int)"`
Host *ConfigValue `parser:"('host' @@)"`
Port *IntValue `parser:"| ('port' @@)"`
DatabaseURL *ConfigValue `parser:"| ('database_url' @@)"`
APIKey *ConfigValue `parser:"| ('api_key' @@)"`
SSLCert *ConfigValue `parser:"| ('ssl_cert' @@)"`
SSLKey *ConfigValue `parser:"| ('ssl_key' @@)"`
}
// Clean entity syntax with better readability
// IntValue Similar to ConfigValue but for integers
type IntValue struct {
Literal *int `parser:"@Int"`
EnvVar *EnvVar `parser:"| @@"`
}
// Entity Clean entity syntax with better readability
type Entity struct {
Name string `parser:"'entity' @Ident"`
Description *string `parser:"('desc' @String)?"`
Fields []Field `parser:"('{' @@* '}')?"` // Block-delimited fields for consistency
}
// Much cleaner field syntax
// Field Detailed field syntax
type Field struct {
Name string `parser:"@Ident ':'"`
Type string `parser:"@Ident"`
@ -45,15 +68,24 @@ type Field struct {
Default *string `parser:"('default' @String)?"`
Validations []Validation `parser:"@@*"`
Relationship *Relationship `parser:"@@?"`
Endpoints []string `parser:"('endpoints' '[' @Ident (',' @Ident)* ']')?"` // with transforms this might not be needed
Transform []Transform `parser:"@@*"`
}
// Simple validation syntax
// Transform Field transformation specification
type Transform struct {
Type string `parser:"'transform' @Ident"`
Column *string `parser:"('to' @Ident)?"`
Direction *string `parser:"('on' @('input' | 'output' | 'both'))?"`
}
// Validation Simple validation syntax
type Validation struct {
Type string `parser:"'validate' @Ident"`
Value *string `parser:"@String?"`
}
// Clear relationship syntax
// Relationship Clear relationship syntax
type Relationship struct {
Type string `parser:"'relates' 'to' @Ident"`
Cardinality string `parser:"'as' @('one' | 'many')"`
@ -73,7 +105,7 @@ type Endpoint struct {
CustomLogic *string `parser:"('custom' @String)? '}')?"` // Close block after all content
}
// Clean parameter syntax
// EndpointParam Clean parameter syntax
type EndpointParam struct {
Name string `parser:"'param' @Ident ':'"`
Type string `parser:"@Ident"`
@ -81,33 +113,38 @@ type EndpointParam struct {
Source string `parser:"'from' @('path' | 'query' | 'body')"`
}
// Response specification
// ResponseSpec Response specification
type ResponseSpec struct {
Type string `parser:"'returns' @Ident"`
Format *string `parser:"('as' @String)?"`
Fields []string `parser:"('fields' '[' @Ident (',' @Ident)* ']')?"`
}
// Enhanced Page definitions with unified section model
// Page Enhanced Page definitions with unified section model
type Page struct {
Name string `parser:"'page' @Ident"`
Path string `parser:"'at' @String"`
Layout string `parser:"'layout' @Ident"`
Title *string `parser:"('title' @String)?"`
Description *string `parser:"('desc' @String)?"`
Auth bool `parser:"@'auth'?"`
Meta []MetaTag `parser:"('{' @@*"` // Block-delimited content
Sections []Section `parser:"@@*"` // Unified sections replace containers/tabs/panels/modals
Components []Component `parser:"@@* '}')?"` // Direct components within the block
Name string `parser:"'page' @Ident"`
Path string `parser:"'at' @String"`
Layout string `parser:"'layout' @Ident"`
Title *string `parser:"('title' @String)?"`
Description *string `parser:"('desc' @String)?"`
Auth bool `parser:"@'auth'?"`
Meta []MetaTag `parser:"('{' @@*"` // Block-delimited content
Elements []PageElement `parser:"@@* '}')?"` // Unified elements allowing any order
}
// Meta tags for SEO
// PageElement Unified element type for pages allowing sections and components in any order
type PageElement struct {
Section *Section `parser:"@@"`
Component *Component `parser:"| @@"`
}
// MetaTag Meta tags for SEO
type MetaTag struct {
Name string `parser:"'meta' @Ident"`
Content string `parser:"@String"`
}
// Unified Section type that replaces Container, Tab, Panel, Modal, MasterDetail
// Section Unified Section type that replaces Container, Tab, Panel, Modal, MasterDetail
type Section struct {
Name string `parser:"'section' @Ident"`
Type *string `parser:"('type' @('container' | 'tab' | 'panel' | 'modal' | 'master' | 'detail'))?"`
@ -120,7 +157,7 @@ type Section struct {
Elements []SectionElement `parser:"('{' @@* '}')?"` // Block-delimited elements for unambiguous nesting
}
// New unified element type for sections
// SectionElement New unified element type for sections
type SectionElement struct {
Attribute *SectionAttribute `parser:"@@"`
Component *Component `parser:"| @@"`
@ -128,7 +165,7 @@ type SectionElement struct {
When *WhenCondition `parser:"| @@"`
}
// Flexible section attributes (replaces complex config types)
// SectionAttribute Flexible section attributes (replaces complex config types)
type SectionAttribute struct {
DataSource *string `parser:"('data' 'from' @String)"`
Style *string `parser:"| ('style' @String)"`
@ -137,14 +174,14 @@ type SectionAttribute struct {
Theme *string `parser:"| ('theme' @String)"`
}
// Simplified Component with unified attributes - reordered for better parsing
// Component Simplified Component with unified attributes - reordered for better parsing
type Component struct {
Type string `parser:"'component' @Ident"`
Entity *string `parser:"('for' @Ident)?"`
Elements []ComponentElement `parser:"('{' @@* '}')?"` // Parse everything inside the block
}
// Enhanced ComponentElement with recursive section support - now includes attributes
// ComponentElement Enhanced ComponentElement with recursive section support - now includes attributes
type ComponentElement struct {
Attribute *ComponentAttr `parser:"@@"` // Component attributes can be inside the block
Field *ComponentField `parser:"| @@"`
@ -153,7 +190,7 @@ type ComponentElement struct {
When *WhenCondition `parser:"| @@"`
}
// Simplified component attributes using key-value pattern - reordered for precedence
// ComponentAttr Simplified component attributes using key-value pattern - reordered for precedence
type ComponentAttr struct {
DataSource *string `parser:"('data' 'from' @String)"`
Fields []string `parser:"| ('fields' '[' @Ident (',' @Ident)* ']')"`
@ -164,14 +201,14 @@ type ComponentAttr struct {
Validate bool `parser:"| @'validate'"`
}
// Enhanced component field with detailed configuration using flexible attributes
// ComponentField Enhanced component field with detailed configuration using flexible attributes
type ComponentField struct {
Name string `parser:"'field' @Ident"`
Type string `parser:"'type' @Ident"`
Attributes []ComponentFieldAttribute `parser:"@@* ('{' @@* '}')?"` // Support both inline and block attributes
}
// Flexible field attribute system
// ComponentFieldAttribute Flexible field attribute system
type ComponentFieldAttribute struct {
Label *string `parser:"('label' @String)"`
Placeholder *string `parser:"| ('placeholder' @String)"`
@ -192,18 +229,18 @@ type ComponentFieldAttribute struct {
Validation *ComponentValidation `parser:"| @@"`
}
// Field relationship for autocomplete and select fields
// FieldRelation Field relationship for autocomplete and select fields
type FieldRelation struct {
Type string `parser:"'relates' 'to' @Ident"`
}
// Component validation
// ComponentValidation Component validation
type ComponentValidation struct {
Type string `parser:"'validate' @Ident"`
Value *string `parser:"@String?"`
}
// Enhanced WhenCondition with recursive support for both sections and components
// WhenCondition Enhanced WhenCondition with recursive support for both sections and components
type WhenCondition struct {
Field string `parser:"'when' @Ident"`
Operator string `parser:"@('equals' | 'not_equals' | 'contains')"`
@ -214,14 +251,14 @@ type WhenCondition struct {
Buttons []ComponentButton `parser:"@@* '}')?"` // Block-delimited for unambiguous nesting
}
// Simplified button with flexible attribute ordering
// ComponentButton Simplified button with flexible attribute ordering
type ComponentButton struct {
Name string `parser:"'button' @Ident"`
Label string `parser:"'label' @String"`
Attributes []ComponentButtonAttr `parser:"@@*"`
}
// Flexible button attribute system - each attribute is a separate alternative
// ComponentButtonAttr Flexible button attribute system - each attribute is a separate alternative
type ComponentButtonAttr struct {
Style *ComponentButtonStyle `parser:"@@"`
Icon *ComponentButtonIcon `parser:"| @@"`
@ -233,7 +270,7 @@ type ComponentButtonAttr struct {
Via *ComponentButtonVia `parser:"| @@"`
}
// Individual button attribute types
// ComponentButtonStyle Individual button attribute types
type ComponentButtonStyle struct {
Value string `parser:"'style' @String"`
}

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

View File

@ -4,6 +4,35 @@ import (
"testing"
)
// Helper functions for creating ConfigValue and IntValue instances
func literalConfigValue(value string) *ConfigValue {
return &ConfigValue{Literal: &value}
}
func literalIntValue(value int) *IntValue {
return &IntValue{Literal: &value}
}
func envConfigValue(name string, defaultValue *string, required bool) *ConfigValue {
return &ConfigValue{
EnvVar: &EnvVar{
Name: name,
Default: defaultValue,
Required: required,
},
}
}
func envIntValue(name string, defaultValue *string, required bool) *IntValue {
return &IntValue{
EnvVar: &EnvVar{
Name: name,
Default: defaultValue,
Required: required,
},
}
}
func TestParseServerDefinitions(t *testing.T) {
tests := []struct {
name string
@ -23,8 +52,8 @@ func TestParseServerDefinitions(t *testing.T) {
Server: &Server{
Name: "MyApp",
Settings: []ServerSetting{
{Host: stringPtr("localhost")},
{Port: intPtr(8080)},
{Host: literalConfigValue("localhost")},
{Port: literalIntValue(8080)},
},
},
},
@ -43,7 +72,7 @@ func TestParseServerDefinitions(t *testing.T) {
Server: &Server{
Name: "WebApp",
Settings: []ServerSetting{
{Host: stringPtr("0.0.0.0")},
{Host: literalConfigValue("0.0.0.0")},
},
},
},
@ -62,7 +91,7 @@ func TestParseServerDefinitions(t *testing.T) {
Server: &Server{
Name: "APIServer",
Settings: []ServerSetting{
{Port: intPtr(3000)},
{Port: literalIntValue(3000)},
},
},
},

View File

@ -4,13 +4,121 @@ import (
"testing"
)
func TestParseAdvancedUIFeatures(t *testing.T) {
func TestParseAdvancedUIStructures(t *testing.T) {
tests := []struct {
name string
input string
want AST
wantErr bool
}{
{
name: "complex form with validation and conditional fields",
input: `page Test at "/test" layout main {
component form for User {
field name type text required validate min_length "3"
field email type email required validate email
field account_type type select options ["personal", "business"] default "personal"
when account_type equals "business" {
field company_name type text required
field tax_id type text validate pattern "[0-9]{9}"
}
button submit label "Create Account" style "primary"
button cancel label "Cancel" style "secondary"
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Test",
Path: "/test",
Layout: "main",
Elements: []PageElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
},
},
},
{
Field: &ComponentField{
Name: "email",
Type: "email",
Attributes: []ComponentFieldAttribute{
{Required: true},
{Validation: &ComponentValidation{Type: "email"}},
},
},
},
{
Field: &ComponentField{
Name: "account_type",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"personal", "business"}},
{Default: stringPtr("personal")},
},
},
},
{
When: &WhenCondition{
Field: "account_type",
Operator: "equals",
Value: "business",
Fields: []ComponentField{
{
Name: "company_name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
},
},
{
Name: "tax_id",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Validation: &ComponentValidation{Type: "pattern", Value: stringPtr("[0-9]{9}")}},
},
},
},
},
},
{
Button: &ComponentButton{
Name: "submit",
Label: "Create Account",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
},
},
},
{
Button: &ComponentButton{
Name: "cancel",
Label: "Cancel",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
},
},
},
},
},
},
},
},
},
},
},
},
{
name: "complex conditional rendering with multiple operators",
input: `page Test at "/test" layout main {
@ -42,86 +150,88 @@ func TestParseAdvancedUIFeatures(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "status",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"active", "inactive", "pending"}},
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "status",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"active", "inactive", "pending"}},
},
},
},
},
{
When: &WhenCondition{
Field: "status",
Operator: "equals",
Value: "active",
Fields: []ComponentField{
{Name: "last_login", Type: "datetime"},
{Name: "permissions", Type: "multiselect"},
},
Buttons: []ComponentButton{
{
Name: "deactivate",
Label: "Deactivate User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "warning"}},
{
When: &WhenCondition{
Field: "status",
Operator: "equals",
Value: "active",
Fields: []ComponentField{
{Name: "last_login", Type: "datetime"},
{Name: "permissions", Type: "multiselect"},
},
Buttons: []ComponentButton{
{
Name: "deactivate",
Label: "Deactivate User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "warning"}},
},
},
},
},
},
},
{
When: &WhenCondition{
Field: "status",
Operator: "not_equals",
Value: "active",
Fields: []ComponentField{
{
Name: "reason",
Type: "textarea",
Attributes: []ComponentFieldAttribute{
{Placeholder: stringPtr("Reason for status")},
{
When: &WhenCondition{
Field: "status",
Operator: "not_equals",
Value: "active",
Fields: []ComponentField{
{
Name: "reason",
Type: "textarea",
Attributes: []ComponentFieldAttribute{
{Placeholder: stringPtr("Reason for status")},
},
},
},
},
Buttons: []ComponentButton{
{
Name: "activate",
Label: "Activate User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "success"}},
Buttons: []ComponentButton{
{
Name: "activate",
Label: "Activate User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "success"}},
},
},
},
},
},
},
{
When: &WhenCondition{
Field: "status",
Operator: "contains",
Value: "pending",
Fields: []ComponentField{
{Name: "approval_date", Type: "date"},
},
Buttons: []ComponentButton{
{
Name: "approve",
Label: "Approve",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
},
{
When: &WhenCondition{
Field: "status",
Operator: "contains",
Value: "pending",
Fields: []ComponentField{
{Name: "approval_date", Type: "date"},
},
{
Name: "reject",
Label: "Reject",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
Buttons: []ComponentButton{
{
Name: "approve",
Label: "Approve",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
},
},
{
Name: "reject",
Label: "Reject",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
},
},
},
},
@ -203,104 +313,106 @@ func TestParseAdvancedUIFeatures(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "form",
Entity: stringPtr("Product"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Product Name")},
{Placeholder: stringPtr("Enter product name")},
{Required: true},
{Default: stringPtr("New Product")},
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
{Size: stringPtr("large")},
{Display: stringPtr("block")},
Component: &Component{
Type: "form",
Entity: stringPtr("Product"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Product Name")},
{Placeholder: stringPtr("Enter product name")},
{Required: true},
{Default: stringPtr("New Product")},
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
{Size: stringPtr("large")},
{Display: stringPtr("block")},
},
},
},
},
{
Field: &ComponentField{
Name: "price",
Type: "number",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Price ($)")},
{Format: stringPtr("currency")},
{Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}},
{Validation: &ComponentValidation{Type: "max", Value: stringPtr("10000")}},
{
Field: &ComponentField{
Name: "price",
Type: "number",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Price ($)")},
{Format: stringPtr("currency")},
{Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}},
{Validation: &ComponentValidation{Type: "max", Value: stringPtr("10000")}},
},
},
},
},
{
Field: &ComponentField{
Name: "category",
Type: "autocomplete",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Category")},
{Placeholder: stringPtr("Start typing...")},
{Relates: &FieldRelation{Type: "Category"}},
{Searchable: true},
{Source: stringPtr("categories/search")},
{
Field: &ComponentField{
Name: "category",
Type: "autocomplete",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Category")},
{Placeholder: stringPtr("Start typing...")},
{Relates: &FieldRelation{Type: "Category"}},
{Searchable: true},
{Source: stringPtr("categories/search")},
},
},
},
},
{
Field: &ComponentField{
Name: "tags",
Type: "multiselect",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Tags")},
{Options: []string{"electronics", "clothing", "books", "home"}},
{Source: stringPtr("tags/popular")},
{
Field: &ComponentField{
Name: "tags",
Type: "multiselect",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Tags")},
{Options: []string{"electronics", "clothing", "books", "home"}},
{Source: stringPtr("tags/popular")},
},
},
},
},
{
Field: &ComponentField{
Name: "description",
Type: "richtext",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Description")},
{Rows: intPtr(10)},
{Placeholder: stringPtr("Describe your product...")},
{
Field: &ComponentField{
Name: "description",
Type: "richtext",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Description")},
{Rows: intPtr(10)},
{Placeholder: stringPtr("Describe your product...")},
},
},
},
},
{
Field: &ComponentField{
Name: "thumbnail",
Type: "image",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Product Image")},
{Accept: stringPtr("image/jpeg,image/png")},
{Thumbnail: true},
{
Field: &ComponentField{
Name: "thumbnail",
Type: "image",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Product Image")},
{Accept: stringPtr("image/jpeg,image/png")},
{Thumbnail: true},
},
},
},
},
{
Field: &ComponentField{
Name: "featured",
Type: "checkbox",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Featured Product")},
{Default: stringPtr("false")},
{Value: stringPtr("true")},
{
Field: &ComponentField{
Name: "featured",
Type: "checkbox",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Featured Product")},
{Default: stringPtr("false")},
{Value: stringPtr("true")},
},
},
},
},
{
Field: &ComponentField{
Name: "availability",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Availability")},
{Options: []string{"in_stock", "out_of_stock", "pre_order"}},
{Default: stringPtr("in_stock")},
{Sortable: true},
{
Field: &ComponentField{
Name: "availability",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Availability")},
{Options: []string{"in_stock", "out_of_stock", "pre_order"}},
{Default: stringPtr("in_stock")},
{Sortable: true},
},
},
},
},
@ -332,76 +444,78 @@ func TestParseAdvancedUIFeatures(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "form",
Entity: stringPtr("Order"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "status",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"draft", "submitted", "approved"}},
Component: &Component{
Type: "form",
Entity: stringPtr("Order"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "status",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"draft", "submitted", "approved"}},
},
},
},
},
{
Button: &ComponentButton{
Name: "save",
Label: "Save Draft",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
{Icon: &ComponentButtonIcon{Value: "save"}},
{Position: &ComponentButtonPosition{Value: "left"}},
{
Button: &ComponentButton{
Name: "save",
Label: "Save Draft",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
{Icon: &ComponentButtonIcon{Value: "save"}},
{Position: &ComponentButtonPosition{Value: "left"}},
},
},
},
},
{
Button: &ComponentButton{
Name: "submit",
Label: "Submit Order",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
{Icon: &ComponentButtonIcon{Value: "send"}},
{Loading: &ComponentButtonLoading{Value: "Submitting..."}},
{Confirm: &ComponentButtonConfirm{Value: "Submit this order?"}},
{
Button: &ComponentButton{
Name: "submit",
Label: "Submit Order",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
{Icon: &ComponentButtonIcon{Value: "send"}},
{Loading: &ComponentButtonLoading{Value: "Submitting..."}},
{Confirm: &ComponentButtonConfirm{Value: "Submit this order?"}},
},
},
},
},
{
Button: &ComponentButton{
Name: "approve",
Label: "Approve",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "success"}},
{Loading: &ComponentButtonLoading{Value: "Approving..."}},
{Disabled: &ComponentButtonDisabled{Value: "status"}},
{Confirm: &ComponentButtonConfirm{Value: "Approve this order?"}},
{Target: &ComponentButtonTarget{Value: "approval_modal"}},
{Via: &ComponentButtonVia{Value: "api/orders/approve"}},
{
Button: &ComponentButton{
Name: "approve",
Label: "Approve",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "success"}},
{Loading: &ComponentButtonLoading{Value: "Approving..."}},
{Disabled: &ComponentButtonDisabled{Value: "status"}},
{Confirm: &ComponentButtonConfirm{Value: "Approve this order?"}},
{Target: &ComponentButtonTarget{Value: "approval_modal"}},
{Via: &ComponentButtonVia{Value: "api/orders/approve"}},
},
},
},
},
{
Button: &ComponentButton{
Name: "reject",
Label: "Reject",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
{Icon: &ComponentButtonIcon{Value: "x"}},
{Confirm: &ComponentButtonConfirm{Value: "Are you sure you want to reject this order?"}},
{
Button: &ComponentButton{
Name: "reject",
Label: "Reject",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
{Icon: &ComponentButtonIcon{Value: "x"}},
{Confirm: &ComponentButtonConfirm{Value: "Are you sure you want to reject this order?"}},
},
},
},
},
{
Button: &ComponentButton{
Name: "print",
Label: "Print",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "outline"}},
{Icon: &ComponentButtonIcon{Value: "printer"}},
{Position: &ComponentButtonPosition{Value: "right"}},
{
Button: &ComponentButton{
Name: "print",
Label: "Print",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "outline"}},
{Icon: &ComponentButtonIcon{Value: "printer"}},
{Position: &ComponentButtonPosition{Value: "right"}},
},
},
},
},
@ -482,12 +596,12 @@ func TestParseFieldValidationTypes(t *testing.T) {
}
page := got.Definitions[0].Page
if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 {
if len(page.Elements) != 1 || page.Elements[0].Component == nil || len(page.Elements[0].Component.Elements) != 1 {
t.Errorf("ParseInput() failed to parse component for validation %s", vt.validation)
return
}
element := page.Components[0].Elements[0]
element := page.Elements[0].Component.Elements[0]
if element.Field == nil || len(element.Field.Attributes) != 1 {
t.Errorf("ParseInput() failed to parse field attributes for validation %s", vt.validation)
return
@ -527,7 +641,7 @@ func TestParseConditionalOperators(t *testing.T) {
// Verify the when condition was parsed correctly
page := got.Definitions[0].Page
component := page.Components[0]
component := page.Elements[0].Component
whenElement := component.Elements[1].When
if whenElement == nil || whenElement.Operator != op {

View File

@ -23,10 +23,12 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "table",
Entity: stringPtr("User"),
Component: &Component{
Type: "table",
Entity: stringPtr("User"),
},
},
},
},
@ -52,58 +54,60 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Full Name")},
{Placeholder: stringPtr("Enter your name")},
{Required: true},
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Full Name")},
{Placeholder: stringPtr("Enter your name")},
{Required: true},
},
},
},
},
{
Field: &ComponentField{
Name: "email",
Type: "email",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Email Address")},
{Required: true},
{
Field: &ComponentField{
Name: "email",
Type: "email",
Attributes: []ComponentFieldAttribute{
{Label: stringPtr("Email Address")},
{Required: true},
},
},
},
},
{
Field: &ComponentField{
Name: "bio",
Type: "textarea",
Attributes: []ComponentFieldAttribute{
{Rows: intPtr(5)},
{Placeholder: stringPtr("Tell us about yourself")},
{
Field: &ComponentField{
Name: "bio",
Type: "textarea",
Attributes: []ComponentFieldAttribute{
{Rows: intPtr(5)},
{Placeholder: stringPtr("Tell us about yourself")},
},
},
},
},
{
Field: &ComponentField{
Name: "avatar",
Type: "file",
Attributes: []ComponentFieldAttribute{
{Accept: stringPtr("image/*")},
{
Field: &ComponentField{
Name: "avatar",
Type: "file",
Attributes: []ComponentFieldAttribute{
{Accept: stringPtr("image/*")},
},
},
},
},
{
Field: &ComponentField{
Name: "role",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"admin", "user", "guest"}},
{Default: stringPtr("user")},
{
Field: &ComponentField{
Name: "role",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"admin", "user", "guest"}},
{Default: stringPtr("user")},
},
},
},
},
@ -135,70 +139,72 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "form",
Entity: stringPtr("Product"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
Component: &Component{
Type: "form",
Entity: stringPtr("Product"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
},
},
},
},
{
Field: &ComponentField{
Name: "price",
Type: "number",
Attributes: []ComponentFieldAttribute{
{Format: stringPtr("currency")},
{Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}},
{
Field: &ComponentField{
Name: "price",
Type: "number",
Attributes: []ComponentFieldAttribute{
{Format: stringPtr("currency")},
{Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}},
},
},
},
},
{
Field: &ComponentField{
Name: "category",
Type: "autocomplete",
Attributes: []ComponentFieldAttribute{
{Relates: &FieldRelation{Type: "Category"}},
{
Field: &ComponentField{
Name: "category",
Type: "autocomplete",
Attributes: []ComponentFieldAttribute{
{Relates: &FieldRelation{Type: "Category"}},
},
},
},
},
{
Field: &ComponentField{
Name: "tags",
Type: "multiselect",
Attributes: []ComponentFieldAttribute{
{Source: stringPtr("tags/popular")},
{
Field: &ComponentField{
Name: "tags",
Type: "multiselect",
Attributes: []ComponentFieldAttribute{
{Source: stringPtr("tags/popular")},
},
},
},
},
{
Field: &ComponentField{
Name: "description",
Type: "richtext",
},
},
{
Field: &ComponentField{
Name: "featured",
Type: "checkbox",
Attributes: []ComponentFieldAttribute{
{Default: stringPtr("false")},
{
Field: &ComponentField{
Name: "description",
Type: "richtext",
},
},
},
{
Field: &ComponentField{
Name: "thumbnail",
Type: "image",
Attributes: []ComponentFieldAttribute{
{Thumbnail: true},
{
Field: &ComponentField{
Name: "featured",
Type: "checkbox",
Attributes: []ComponentFieldAttribute{
{Default: stringPtr("false")},
},
},
},
{
Field: &ComponentField{
Name: "thumbnail",
Type: "image",
Attributes: []ComponentFieldAttribute{
{Thumbnail: true},
},
},
},
},
@ -227,44 +233,46 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
},
},
{
Button: &ComponentButton{
Name: "save",
Label: "Save User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
{Icon: &ComponentButtonIcon{Value: "save"}},
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
},
},
},
{
Button: &ComponentButton{
Name: "cancel",
Label: "Cancel",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
{
Button: &ComponentButton{
Name: "save",
Label: "Save User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
{Icon: &ComponentButtonIcon{Value: "save"}},
},
},
},
},
{
Button: &ComponentButton{
Name: "delete",
Label: "Delete",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
{Confirm: &ComponentButtonConfirm{Value: "Are you sure?"}},
{Disabled: &ComponentButtonDisabled{Value: "is_protected"}},
{
Button: &ComponentButton{
Name: "cancel",
Label: "Cancel",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
},
},
},
{
Button: &ComponentButton{
Name: "delete",
Label: "Delete",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "danger"}},
{Confirm: &ComponentButtonConfirm{Value: "Are you sure?"}},
{Disabled: &ComponentButtonDisabled{Value: "is_protected"}},
},
},
},
},
@ -298,55 +306,57 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "account_type",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"personal", "business"}},
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "account_type",
Type: "select",
Attributes: []ComponentFieldAttribute{
{Options: []string{"personal", "business"}},
},
},
},
},
{
When: &WhenCondition{
Field: "account_type",
Operator: "equals",
Value: "business",
Fields: []ComponentField{
{
Name: "company_name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
{
When: &WhenCondition{
Field: "account_type",
Operator: "equals",
Value: "business",
Fields: []ComponentField{
{
Name: "company_name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
},
},
{
Name: "tax_id",
Type: "text",
},
},
{
Name: "tax_id",
Type: "text",
},
},
Buttons: []ComponentButton{
{
Name: "verify_business",
Label: "Verify Business",
Buttons: []ComponentButton{
{
Name: "verify_business",
Label: "Verify Business",
},
},
},
},
},
{
When: &WhenCondition{
Field: "account_type",
Operator: "equals",
Value: "personal",
Fields: []ComponentField{
{
Name: "date_of_birth",
Type: "date",
{
When: &WhenCondition{
Field: "account_type",
Operator: "equals",
Value: "personal",
Fields: []ComponentField{
{
Name: "date_of_birth",
Type: "date",
},
},
},
},
@ -383,36 +393,38 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Components: []Component{
Elements: []PageElement{
{
Type: "dashboard",
Elements: []ComponentElement{
{
Section: &Section{
Name: "stats",
Type: stringPtr("container"),
Class: stringPtr("stats-grid"),
Elements: []SectionElement{
{
Component: &Component{
Type: "metric",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "total_users",
Type: "display",
Attributes: []ComponentFieldAttribute{
{Value: stringPtr("1,234")},
Component: &Component{
Type: "dashboard",
Elements: []ComponentElement{
{
Section: &Section{
Name: "stats",
Type: stringPtr("container"),
Class: stringPtr("stats-grid"),
Elements: []SectionElement{
{
Component: &Component{
Type: "metric",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "total_users",
Type: "display",
Attributes: []ComponentFieldAttribute{
{Value: stringPtr("1,234")},
},
},
},
},
{
Field: &ComponentField{
Name: "revenue",
Type: "display",
Attributes: []ComponentFieldAttribute{
{Format: stringPtr("currency")},
{Value: stringPtr("45,678")},
{
Field: &ComponentField{
Name: "revenue",
Type: "display",
Attributes: []ComponentFieldAttribute{
{Format: stringPtr("currency")},
{Value: stringPtr("45,678")},
},
},
},
},
@ -421,20 +433,20 @@ func TestParseComponentDefinitions(t *testing.T) {
},
},
},
},
{
Section: &Section{
Name: "charts",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "chart",
Entity: stringPtr("Analytics"),
Elements: []ComponentElement{
{
Attribute: &ComponentAttr{
DataSource: stringPtr("analytics/monthly"),
{
Section: &Section{
Name: "charts",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "chart",
Entity: stringPtr("Analytics"),
Elements: []ComponentElement{
{
Attribute: &ComponentAttr{
DataSource: stringPtr("analytics/monthly"),
},
},
},
},
@ -461,7 +473,7 @@ func TestParseComponentDefinitions(t *testing.T) {
return
}
if !astEqual(got, tt.want) {
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
t.Errorf("ParseInput() got = %+v, want %+v", got, tt.want)
}
})
}
@ -495,14 +507,20 @@ func TestParseComponentFieldTypes(t *testing.T) {
}
page := got.Definitions[0].Page
if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 {
if len(page.Elements) != 1 {
t.Errorf("ParseInput() failed to parse component for field type %s", fieldType)
return
}
element := page.Components[0].Elements[0]
if element.Field == nil || element.Field.Type != fieldType {
t.Errorf("ParseInput() field type mismatch: got %v, want %s", element.Field, fieldType)
element := page.Elements[0]
if element.Component == nil || len(element.Component.Elements) != 1 {
t.Errorf("ParseInput() failed to parse component for field type %s", fieldType)
return
}
fieldElement := element.Component.Elements[0]
if fieldElement.Field == nil || fieldElement.Field.Type != fieldType {
t.Errorf("ParseInput() field type mismatch: got %v, want %s", fieldElement.Field, fieldType)
}
})
}

View File

@ -12,14 +12,14 @@ func TestParsePageDefinitions(t *testing.T) {
wantErr bool
}{
{
name: "basic page with minimal fields",
input: `page Dashboard at "/dashboard" layout main`,
name: "basic page definition",
input: `page Home at "/" layout main`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Dashboard",
Path: "/dashboard",
Name: "Home",
Path: "/",
Layout: "main",
},
},
@ -27,17 +27,17 @@ func TestParsePageDefinitions(t *testing.T) {
},
},
{
name: "page with all optional fields",
input: `page UserProfile at "/profile" layout main title "User Profile" desc "Manage user profile settings" auth`,
name: "page with optional fields",
input: `page Settings at "/settings" layout main title "User Settings" desc "Manage your account settings" auth`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "UserProfile",
Path: "/profile",
Name: "Settings",
Path: "/settings",
Layout: "main",
Title: stringPtr("User Profile"),
Description: stringPtr("Manage user profile settings"),
Title: stringPtr("User Settings"),
Description: stringPtr("Manage your account settings"),
Auth: true,
},
},
@ -46,20 +46,22 @@ func TestParsePageDefinitions(t *testing.T) {
},
{
name: "page with meta tags",
input: `page HomePage at "/" layout main {
meta description "Welcome to our application"
meta keywords "app, dashboard, management"
input: `page Settings at "/settings" layout main {
meta description "Settings page description"
meta keywords "settings, user, account"
meta author "My App"
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "HomePage",
Path: "/",
Name: "Settings",
Path: "/settings",
Layout: "main",
Meta: []MetaTag{
{Name: "description", Content: "Welcome to our application"},
{Name: "keywords", Content: "app, dashboard, management"},
{Name: "description", Content: "Settings page description"},
{Name: "keywords", Content: "settings, user, account"},
{Name: "author", Content: "My App"},
},
},
},
@ -67,19 +69,17 @@ func TestParsePageDefinitions(t *testing.T) {
},
},
{
name: "page with nested sections",
name: "page with sections",
input: `page Settings at "/settings" layout main {
section tabs type tab {
section profile label "Profile" active {
component form for User {
field name type text
}
component form for User
}
section security label "Security" {
component form for User {
field password type password
}
component form for Security
}
section notifications label "Notifications" {
component toggle for NotificationSettings
}
}
}`,
@ -90,50 +90,50 @@ func TestParsePageDefinitions(t *testing.T) {
Name: "Settings",
Path: "/settings",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "tabs",
Type: stringPtr("tab"),
Elements: []SectionElement{
{
Section: &Section{
Name: "profile",
Label: stringPtr("Profile"),
Active: true,
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
},
},
Section: &Section{
Name: "tabs",
Type: stringPtr("tab"),
Elements: []SectionElement{
{
Section: &Section{
Name: "profile",
Label: stringPtr("Profile"),
Active: true,
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
},
},
},
},
},
},
{
Section: &Section{
Name: "security",
Label: stringPtr("Security"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "password",
Type: "password",
},
},
{
Section: &Section{
Name: "security",
Label: stringPtr("Security"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("Security"),
},
},
},
},
},
{
Section: &Section{
Name: "notifications",
Label: stringPtr("Notifications"),
Elements: []SectionElement{
{
Component: &Component{
Type: "toggle",
Entity: stringPtr("NotificationSettings"),
},
},
},
@ -149,71 +149,124 @@ func TestParsePageDefinitions(t *testing.T) {
},
},
{
name: "page with modal and panel sections",
input: `page ProductList at "/products" layout main {
section main type container {
component table for Product
name: "page with components",
input: `page Dashboard at "/dashboard" layout main {
component stats for Analytics {
field total_users type display
field revenue type display format "currency"
}
section editModal type modal trigger "edit-product" {
component form for Product {
field name type text required
button save label "Save Changes" style "primary"
}
}
section filters type panel position "left" {
component form {
field category type select
field price_range type range
}
component chart for SalesData {
data from "analytics/sales"
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "ProductList",
Path: "/products",
Name: "Dashboard",
Path: "/dashboard",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "main",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("Product"),
Component: &Component{
Type: "stats",
Entity: stringPtr("Analytics"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "total_users",
Type: "display",
},
},
{
Field: &ComponentField{
Name: "revenue",
Type: "display",
Attributes: []ComponentFieldAttribute{
{Format: stringPtr("currency")},
},
},
},
},
},
},
{
Name: "editModal",
Type: stringPtr("modal"),
Trigger: stringPtr("edit-product"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("Product"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
},
},
},
{
Button: &ComponentButton{
Name: "save",
Label: "Save Changes",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
Component: &Component{
Type: "chart",
Entity: stringPtr("SalesData"),
Elements: []ComponentElement{
{
Attribute: &ComponentAttr{
DataSource: stringPtr("analytics/sales"),
},
},
},
},
},
},
},
},
},
},
},
{
name: "page with mixed sections and components",
input: `page Home at "/" layout main {
component hero for Banner {
field title type display
field subtitle type display
}
section content type container {
component posts for Post {
fields [title, excerpt, date]
}
}
component newsletter for Subscription {
field email type email required
button subscribe label "Subscribe"
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Home",
Path: "/",
Layout: "main",
Elements: []PageElement{
{
Component: &Component{
Type: "hero",
Entity: stringPtr("Banner"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "title",
Type: "display",
},
},
{
Field: &ComponentField{
Name: "subtitle",
Type: "display",
},
},
},
},
},
{
Section: &Section{
Name: "content",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "posts",
Entity: stringPtr("Post"),
Elements: []ComponentElement{
{
Attribute: &ComponentAttr{
Fields: []string{"title", "excerpt", "date"},
},
},
},
@ -223,28 +276,25 @@ func TestParsePageDefinitions(t *testing.T) {
},
},
{
Name: "filters",
Type: stringPtr("panel"),
Position: stringPtr("left"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "category",
Type: "select",
},
},
{
Field: &ComponentField{
Name: "price_range",
Type: "range",
},
Component: &Component{
Type: "newsletter",
Entity: stringPtr("Subscription"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "email",
Type: "email",
Attributes: []ComponentFieldAttribute{
{Required: true},
},
},
},
{
Button: &ComponentButton{
Name: "subscribe",
Label: "Subscribe",
},
},
},
},
},
@ -264,7 +314,66 @@ func TestParsePageDefinitions(t *testing.T) {
return
}
if !astEqual(got, tt.want) {
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
t.Errorf("ParseInput() = %v, want %v", got, tt.want)
}
})
}
}
func TestParsePageLayouts(t *testing.T) {
layouts := []string{"main", "admin", "public", "auth", "minimal", "dashboard"}
for _, layout := range layouts {
t.Run("layout_"+layout, func(t *testing.T) {
input := `page Test at "/test" layout ` + layout
got, err := ParseInput(input)
if err != nil {
t.Errorf("ParseInput() failed for layout %s: %v", layout, err)
return
}
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
t.Errorf("ParseInput() failed to parse page for layout %s", layout)
return
}
page := got.Definitions[0].Page
if page.Layout != layout {
t.Errorf("ParseInput() layout mismatch: got %s, want %s", page.Layout, layout)
}
})
}
}
func TestParsePagePaths(t *testing.T) {
tests := []struct {
name string
path string
}{
{"root", "/"},
{"simple", "/about"},
{"nested", "/admin/users"},
{"deep_nested", "/api/v1/users/profile"},
{"with_params", "/users/:id"},
{"with_multiple_params", "/users/:userId/posts/:postId"},
{"with_query", "/search?q=:query"},
{"with_extension", "/api/users.json"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := `page Test at "` + tt.path + `" layout main`
got, err := ParseInput(input)
if err != nil {
t.Errorf("ParseInput() failed for path %s: %v", tt.path, err)
return
}
page := got.Definitions[0].Page
if page.Path != tt.path {
t.Errorf("ParseInput() path mismatch: got %s, want %s", page.Path, tt.path)
}
})
}
@ -276,16 +385,26 @@ func TestParsePageErrors(t *testing.T) {
input string
}{
{
name: "missing layout",
input: `page Dashboard at "/dashboard"`,
name: "missing page name",
input: `page at "/" layout main`,
},
{
name: "missing path",
input: `page Dashboard layout main`,
input: `page Test layout main`,
},
{
name: "missing layout",
input: `page Test at "/"`,
},
{
name: "invalid path format",
input: `page Dashboard at dashboard layout main`,
input: `page Test at /invalid layout main`,
},
{
name: "unclosed page block",
input: `page Test at "/" layout main {
section test type container
`,
},
}

View File

@ -23,10 +23,12 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "main",
Type: stringPtr("container"),
Section: &Section{
Name: "main",
Type: stringPtr("container"),
},
},
},
},
@ -46,15 +48,57 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "sidebar",
Type: stringPtr("panel"),
Position: stringPtr("left"),
Class: stringPtr("sidebar-nav"),
Label: stringPtr("Navigation"),
Trigger: stringPtr("toggle-sidebar"),
Entity: stringPtr("User"),
Section: &Section{
Name: "sidebar",
Type: stringPtr("panel"),
Class: stringPtr("sidebar-nav"),
Label: stringPtr("Navigation"),
Trigger: stringPtr("toggle-sidebar"),
Position: stringPtr("left"),
Entity: stringPtr("User"),
},
},
},
},
},
},
},
},
{
name: "sections with separate attributes",
input: `page Dashboard at "/dashboard" layout main {
section content type container {
data from "/api/data"
style "padding: 20px"
}
}`,
want: AST{
Definitions: []Definition{
{
Page: &Page{
Name: "Dashboard",
Path: "/dashboard",
Layout: "main",
Elements: []PageElement{
{
Section: &Section{
Name: "content",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Attribute: &SectionAttribute{
DataSource: stringPtr("/api/data"),
},
},
{
Attribute: &SectionAttribute{
Style: stringPtr("padding: 20px"),
},
},
},
},
},
},
},
@ -78,28 +122,30 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "tabs",
Type: stringPtr("tab"),
Elements: []SectionElement{
{
Section: &Section{
Name: "overview",
Label: stringPtr("Overview"),
Active: true,
Section: &Section{
Name: "tabs",
Type: stringPtr("tab"),
Elements: []SectionElement{
{
Section: &Section{
Name: "overview",
Label: stringPtr("Overview"),
Active: true,
},
},
},
{
Section: &Section{
Name: "details",
Label: stringPtr("Details"),
{
Section: &Section{
Name: "details",
Label: stringPtr("Details"),
},
},
},
{
Section: &Section{
Name: "settings",
Label: stringPtr("Settings"),
{
Section: &Section{
Name: "settings",
Label: stringPtr("Settings"),
},
},
},
},
@ -129,50 +175,52 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "userModal",
Type: stringPtr("modal"),
Trigger: stringPtr("edit-user"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
Section: &Section{
Name: "userModal",
Type: stringPtr("modal"),
Trigger: stringPtr("edit-user"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Required: true},
},
},
},
},
{
Field: &ComponentField{
Name: "email",
Type: "email",
Attributes: []ComponentFieldAttribute{
{Required: true},
{
Field: &ComponentField{
Name: "email",
Type: "email",
Attributes: []ComponentFieldAttribute{
{Required: true},
},
},
},
},
{
Button: &ComponentButton{
Name: "save",
Label: "Save Changes",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
{
Button: &ComponentButton{
Name: "save",
Label: "Save Changes",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
},
},
},
},
{
Button: &ComponentButton{
Name: "cancel",
Label: "Cancel",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
{
Button: &ComponentButton{
Name: "cancel",
Label: "Cancel",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "secondary"}},
},
},
},
},
@ -213,24 +261,26 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "masterDetail",
Type: stringPtr("master"),
Elements: []SectionElement{
{
Section: &Section{
Name: "userList",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Attribute: &ComponentAttr{
Fields: []string{"name", "email"},
Section: &Section{
Name: "masterDetail",
Type: stringPtr("master"),
Elements: []SectionElement{
{
Section: &Section{
Name: "userList",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Attribute: &ComponentAttr{
Fields: []string{"name", "email"},
},
},
},
},
@ -238,35 +288,35 @@ func TestParseSectionDefinitions(t *testing.T) {
},
},
},
},
{
Section: &Section{
Name: "userDetail",
Type: stringPtr("detail"),
Trigger: stringPtr("user-selected"),
Entity: stringPtr("User"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
{
Section: &Section{
Name: "userDetail",
Type: stringPtr("detail"),
Trigger: stringPtr("user-selected"),
Entity: stringPtr("User"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("User"),
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "name",
Type: "text",
},
},
},
{
Field: &ComponentField{
Name: "email",
Type: "email",
{
Field: &ComponentField{
Name: "email",
Type: "email",
},
},
},
{
Field: &ComponentField{
Name: "bio",
Type: "textarea",
{
Field: &ComponentField{
Name: "bio",
Type: "textarea",
},
},
},
},
@ -323,27 +373,29 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "mainLayout",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Section: &Section{
Name: "header",
Type: stringPtr("container"),
Class: stringPtr("header"),
Elements: []SectionElement{
{
Component: &Component{
Type: "navbar",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "search",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Placeholder: stringPtr("Search...")},
Section: &Section{
Name: "mainLayout",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Section: &Section{
Name: "header",
Type: stringPtr("container"),
Class: stringPtr("header"),
Elements: []SectionElement{
{
Component: &Component{
Type: "navbar",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "search",
Type: "text",
Attributes: []ComponentFieldAttribute{
{Placeholder: stringPtr("Search...")},
},
},
},
},
@ -352,26 +404,26 @@ func TestParseSectionDefinitions(t *testing.T) {
},
},
},
},
{
Section: &Section{
Name: "content",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Section: &Section{
Name: "sidebar",
Type: stringPtr("panel"),
Position: stringPtr("left"),
Elements: []SectionElement{
{
Component: &Component{
Type: "menu",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "navigation",
Type: "list",
{
Section: &Section{
Name: "content",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Section: &Section{
Name: "sidebar",
Type: stringPtr("panel"),
Position: stringPtr("left"),
Elements: []SectionElement{
{
Component: &Component{
Type: "menu",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "navigation",
Type: "list",
},
},
},
},
@ -379,31 +431,31 @@ func TestParseSectionDefinitions(t *testing.T) {
},
},
},
},
{
Section: &Section{
Name: "main",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Section: &Section{
Name: "tabs",
Type: stringPtr("tab"),
Elements: []SectionElement{
{
Section: &Section{
Name: "overview",
Label: stringPtr("Overview"),
Active: true,
Elements: []SectionElement{
{
Component: &Component{
Type: "dashboard",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "stats",
Type: "metric",
{
Section: &Section{
Name: "main",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Section: &Section{
Name: "tabs",
Type: stringPtr("tab"),
Elements: []SectionElement{
{
Section: &Section{
Name: "overview",
Label: stringPtr("Overview"),
Active: true,
Elements: []SectionElement{
{
Component: &Component{
Type: "dashboard",
Elements: []ComponentElement{
{
Field: &ComponentField{
Name: "stats",
Type: "metric",
},
},
},
},
@ -411,16 +463,16 @@ func TestParseSectionDefinitions(t *testing.T) {
},
},
},
},
{
Section: &Section{
Name: "reports",
Label: stringPtr("Reports"),
Elements: []SectionElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("Report"),
{
Section: &Section{
Name: "reports",
Label: stringPtr("Reports"),
Elements: []SectionElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("Report"),
},
},
},
},
@ -464,37 +516,39 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test",
Path: "/test",
Layout: "main",
Sections: []Section{
Elements: []PageElement{
{
Name: "adminPanel",
Type: stringPtr("container"),
Elements: []SectionElement{
{
When: &WhenCondition{
Field: "user_role",
Operator: "equals",
Value: "admin",
Sections: []Section{
{
Name: "userManagement",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("User"),
Section: &Section{
Name: "adminPanel",
Type: stringPtr("container"),
Elements: []SectionElement{
{
When: &WhenCondition{
Field: "user_role",
Operator: "equals",
Value: "admin",
Sections: []Section{
{
Name: "userManagement",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "table",
Entity: stringPtr("User"),
},
},
},
},
},
{
Name: "systemSettings",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("Settings"),
{
Name: "systemSettings",
Type: stringPtr("container"),
Elements: []SectionElement{
{
Component: &Component{
Type: "form",
Entity: stringPtr("Settings"),
},
},
},
},
@ -520,7 +574,7 @@ func TestParseSectionDefinitions(t *testing.T) {
return
}
if !astEqual(got, tt.want) {
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
t.Errorf("ParseInput() got = %+v, want %+v", got, tt.want)
}
})
}
@ -549,12 +603,12 @@ func TestParseSectionTypes(t *testing.T) {
}
page := got.Definitions[0].Page
if len(page.Sections) != 1 {
if len(page.Elements) != 1 || page.Elements[0].Section == nil {
t.Errorf("ParseInput() failed to parse section for type %s", sectionType)
return
}
section := page.Sections[0]
section := page.Elements[0].Section
if section.Type == nil || *section.Type != sectionType {
t.Errorf("ParseInput() section type mismatch: got %v, want %s", section.Type, sectionType)
}

View File

@ -21,7 +21,68 @@ func serverEqual(got, want Server) bool {
}
func serverSettingEqual(got, want ServerSetting) bool {
return stringPtrEqual(got.Host, want.Host) && intPtrEqual(got.Port, want.Port)
return configValueEqual(got.Host, want.Host) &&
intValueEqual(got.Port, want.Port) &&
configValueEqual(got.DatabaseURL, want.DatabaseURL) &&
configValueEqual(got.APIKey, want.APIKey) &&
configValueEqual(got.SSLCert, want.SSLCert) &&
configValueEqual(got.SSLKey, want.SSLKey)
}
func configValueEqual(got, want *ConfigValue) bool {
if got == nil && want == nil {
return true
}
if got == nil || want == nil {
return false
}
// Check literal values
if got.Literal != nil && want.Literal != nil {
return *got.Literal == *want.Literal
}
if got.Literal != nil || want.Literal != nil {
return false
}
// Check environment variables
if got.EnvVar != nil && want.EnvVar != nil {
return envVarEqual(*got.EnvVar, *want.EnvVar)
}
return got.EnvVar == nil && want.EnvVar == nil
}
func intValueEqual(got, want *IntValue) bool {
if got == nil && want == nil {
return true
}
if got == nil || want == nil {
return false
}
// Check literal values
if got.Literal != nil && want.Literal != nil {
return *got.Literal == *want.Literal
}
if got.Literal != nil || want.Literal != nil {
return false
}
// Check environment variables
if got.EnvVar != nil && want.EnvVar != nil {
return envVarEqual(*got.EnvVar, *want.EnvVar)
}
return got.EnvVar == nil && want.EnvVar == nil
}
func envVarEqual(got, want EnvVar) bool {
if got.Name != want.Name {
return false
}
if got.Required != want.Required {
return false
}
return stringPtrEqual(got.Default, want.Default)
}
func entityEqual(got, want Entity) bool {

View File

@ -30,24 +30,13 @@ func pageEqual(got, want Page) bool {
}
}
// Compare sections (unified model)
if len(got.Sections) != len(want.Sections) {
// Compare elements (unified model)
if len(got.Elements) != len(want.Elements) {
return false
}
for i, section := range got.Sections {
if !sectionEqual(section, want.Sections[i]) {
return false
}
}
// Compare components
if len(got.Components) != len(want.Components) {
return false
}
for i, component := range got.Components {
if !componentEqual(component, want.Components[i]) {
for i, element := range got.Elements {
if !pageElementEqual(element, want.Elements[i]) {
return false
}
}
@ -55,6 +44,28 @@ func pageEqual(got, want Page) bool {
return true
}
func pageElementEqual(got, want PageElement) bool {
// Both should have either a Section or Component, but not both
if (got.Section == nil) != (want.Section == nil) {
return false
}
if (got.Component == nil) != (want.Component == nil) {
return false
}
// Compare sections if present
if got.Section != nil && want.Section != nil {
return sectionEqual(*got.Section, *want.Section)
}
// Compare components if present
if got.Component != nil && want.Component != nil {
return componentEqual(*got.Component, *want.Component)
}
return false
}
func metaTagEqual(got, want MetaTag) bool {
return got.Name == want.Name && got.Content == want.Content
}

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.