diff --git a/.gitignore b/.gitignore index 109924b..1d81987 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /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/ diff --git a/examples/custom-js-functions/blog-example.masonry b/examples/custom-js-functions/blog-example.masonry new file mode 100644 index 0000000..292377d --- /dev/null +++ b/examples/custom-js-functions/blog-example.masonry @@ -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" +} diff --git a/examples/custom-js-functions/readme.md b/examples/custom-js-functions/readme.md new file mode 100644 index 0000000..8155f6b --- /dev/null +++ b/examples/custom-js-functions/readme.md @@ -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 diff --git a/examples/custom-js-functions/templates/controller.tmpl b/examples/custom-js-functions/templates/controller.tmpl new file mode 100644 index 0000000..f46c011 --- /dev/null +++ b/examples/custom-js-functions/templates/controller.tmpl @@ -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) +} diff --git a/examples/custom-js-functions/templates/manifest.yaml b/examples/custom-js-functions/templates/manifest.yaml new file mode 100644 index 0000000..a3b5a5b --- /dev/null +++ b/examples/custom-js-functions/templates/manifest.yaml @@ -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" diff --git a/examples/custom-js-functions/templates/model.tmpl b/examples/custom-js-functions/templates/model.tmpl new file mode 100644 index 0000000..c589737 --- /dev/null +++ b/examples/custom-js-functions/templates/model.tmpl @@ -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 +} diff --git a/examples/custom-js-functions/templates/router.tmpl b/examples/custom-js-functions/templates/router.tmpl new file mode 100644 index 0000000..83525d3 --- /dev/null +++ b/examples/custom-js-functions/templates/router.tmpl @@ -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 +} diff --git a/go.mod b/go.mod index df2e321..5e5ac1c 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +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/yaml.v3 v3.0.1 // indirect + gopkg.in/sourcemap.v1 v1.0.5 // indirect ) diff --git a/go.sum b/go.sum index 98f4d29..87e440a 100644 --- a/go.sum +++ b/go.sum @@ -6,16 +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= diff --git a/interpreter/template_interpreter.go b/interpreter/template_interpreter.go index b305ff7..1bebfdc 100644 --- a/interpreter/template_interpreter.go +++ b/interpreter/template_interpreter.go @@ -11,6 +11,7 @@ import ( "strings" "text/template" + "github.com/robertkrimen/otto" "golang.org/x/text/cases" "golang.org/x/text/language" "gopkg.in/yaml.v3" @@ -18,9 +19,10 @@ import ( // TemplateManifest defines the structure for multi-file template generation type TemplateManifest struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Outputs []OutputFile `yaml:"outputs"` + 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 @@ -35,12 +37,14 @@ type OutputFile struct { // 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(), } } @@ -115,13 +119,7 @@ func (ti *TemplateInterpreter) Interpret(masonryInput string, tmplText string) ( // 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 + // Load manifest first to get custom functions manifestPath := filepath.Join(templateDir, manifestFile) manifestData, err := os.ReadFile(manifestPath) if err != nil { @@ -134,6 +132,18 @@ func (ti *TemplateInterpreter) InterpretToFiles(masonryFile, templateDir, manife 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 { @@ -259,30 +269,31 @@ func (ti *TemplateInterpreter) getAllSections(ast lang.AST) []interface{} { for _, def := range ast.Definitions { if def.Page != nil { - // Get sections directly in the page - for i := range def.Page.Sections { - sections = append(sections, &def.Page.Sections[i]) + // 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)...) + } } - // Recursively get nested sections - sections = append(sections, ti.getSectionsFromSections(def.Page.Sections)...) } } return sections } -// getSectionsFromSections recursively extracts sections from a list of sections -func (ti *TemplateInterpreter) getSectionsFromSections(sections []lang.Section) []interface{} { +// getSectionsFromSection recursively extracts sections from a single section +func (ti *TemplateInterpreter) getSectionsFromSection(section lang.Section) []interface{} { var result []interface{} - for i := range sections { - for j := range sections[i].Elements { - element := §ions[i].Elements[j] - if element.Section != nil { - result = append(result, element.Section) - // Recursively get sections from this section - result = append(result, ti.getSectionsFromSections([]lang.Section{*element.Section})...) - } + for i := range section.Elements { + element := §ion.Elements[i] + if element.Section != nil { + result = append(result, element.Section) + // Recursively get sections from this section + result = append(result, ti.getSectionsFromSection(*element.Section)...) } } @@ -295,50 +306,52 @@ func (ti *TemplateInterpreter) getAllComponents(ast lang.AST) []interface{} { for _, def := range ast.Definitions { if def.Page != nil { - // Get components directly in the page - for i := range def.Page.Components { - components = append(components, &def.Page.Components[i]) + // 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)...) + } } - // Get components from sections - components = append(components, ti.getComponentsFromSections(def.Page.Sections)...) } } return components } -// getComponentsFromSections recursively extracts components from sections -func (ti *TemplateInterpreter) getComponentsFromSections(sections []lang.Section) []interface{} { +// getComponentsFromSection recursively extracts components from a section +func (ti *TemplateInterpreter) getComponentsFromSection(section lang.Section) []interface{} { var components []interface{} - for i := range sections { - for j := range sections[i].Elements { - element := §ions[i].Elements[j] - if element.Component != nil { - components = append(components, element.Component) - // Get nested components from this component - components = append(components, ti.getComponentsFromComponents([]lang.Component{*element.Component})...) - } else if element.Section != nil { - // Recursively get components from nested sections - components = append(components, ti.getComponentsFromSections([]lang.Section{*element.Section})...) - } + for i := range section.Elements { + element := §ion.Elements[i] + if element.Component != nil { + components = append(components, element.Component) + // Get nested components from this component + components = append(components, ti.getComponentsFromComponent(*element.Component)...) + } else if element.Section != nil { + // Recursively get components from nested sections + components = append(components, ti.getComponentsFromSection(*element.Section)...) } } return components } -// getComponentsFromComponents recursively extracts components from nested components -func (ti *TemplateInterpreter) getComponentsFromComponents(components []lang.Component) []interface{} { +// getComponentsFromComponent recursively extracts components from nested components +func (ti *TemplateInterpreter) getComponentsFromComponent(component lang.Component) []interface{} { var result []interface{} - for i := range components { - for j := range components[i].Elements { - element := &components[i].Elements[j] - if element.Section != nil { - // Get components from nested sections - result = append(result, ti.getComponentsFromSections([]lang.Section{*element.Section})...) - } + 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)...) } } @@ -648,3 +661,107 @@ func (tr *TemplateRegistry) LoadFromDirectory(dir string) error { 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] +} diff --git a/readme.md b/readme.md index 2987695..a403e4f 100644 --- a/readme.md +++ b/readme.md @@ -22,8 +22,8 @@ Masonry is a library that provides and implements all the basics necessary to bu - [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 -- [ ] Yaml file to include custom template functions (Otto for JS) -- [ ] Support multi-file outputs based on a directory+manifest file. +- [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`