refactor template functions to remove duplication
update readme TODOs update plan for multi-file outputs
This commit is contained in:
@ -74,126 +74,8 @@ func (ti *TemplateInterpreter) Interpret(masonryInput string, tmplText string) (
|
||||
return "", fmt.Errorf("error parsing Masonry input: %w", err)
|
||||
}
|
||||
|
||||
// Create template with helper functions
|
||||
tmpl := template.Must(template.New("rootTemplate").Funcs(template.FuncMap{
|
||||
"registry": func() *TemplateRegistry { return ti.registry },
|
||||
"executeTemplate": func(name string, data interface{}) (string, error) {
|
||||
if tmpl, exists := ti.registry.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 := ti.registry.templates[name]
|
||||
return exists
|
||||
},
|
||||
"title": cases.Title(language.English).String,
|
||||
"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 = strings.Title(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
|
||||
},
|
||||
}).Parse(tmplText))
|
||||
// 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
|
||||
@ -259,7 +141,7 @@ func NewTemplateRegistry() *TemplateRegistry {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9]+`)
|
||||
name := re.ReplaceAllString(path, " ")
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.Title(name)
|
||||
name = cases.Title(language.English).String(name)
|
||||
return strings.ReplaceAll(name, " ", "")
|
||||
},
|
||||
"getHost": func(settings []lang.ServerSetting) string {
|
||||
@ -346,6 +228,16 @@ func NewTemplateRegistry() *TemplateRegistry {
|
||||
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 {
|
||||
|
@ -33,7 +33,7 @@ Masonry is a library that provides and implements all the basics necessary to bu
|
||||
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.
|
||||
*
|
||||
|
@ -367,3 +367,320 @@ func generateMultiFileCommand() *cobra.Command {
|
||||
```
|
||||
|
||||
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.Sections}}
|
||||
<section className="{{.Class | derefString}}">
|
||||
{{if .Label}}<h2>{{.Label | derefString}}</h2>{{end}}
|
||||
{{range .Components}}
|
||||
{/* Component: {{.Type}} */}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**`api-endpoint.tmpl`:**
|
||||
```typescript
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
{{if .Endpoint.Auth}}
|
||||
import { requireAuth } from '../../lib/auth';
|
||||
{{end}}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
{{if .Endpoint.Auth}}
|
||||
const user = await requireAuth(req, res);
|
||||
if (!user) return;
|
||||
{{end}}
|
||||
|
||||
if (req.method === '{{.Endpoint.Method}}') {
|
||||
{{range .Endpoint.Params}}
|
||||
const {{.Name}} = req.{{if eq .Source "body"}}body{{else if eq .Source "query"}}query{{else}}params{{end}}.{{.Name}};
|
||||
{{if .Required}}
|
||||
if (!{{.Name}}) {
|
||||
return res.status(400).json({ error: '{{.Name}} is required' });
|
||||
}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
try {
|
||||
{{if .Endpoint.CustomLogic}}
|
||||
// Custom logic: {{.Endpoint.CustomLogic | derefString}}
|
||||
{{else}}
|
||||
// Default CRUD operation for {{.Endpoint.Entity | derefString}}
|
||||
{{end}}
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else {
|
||||
res.setHeader('Allow', ['{{.Endpoint.Method}}']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Masonry Input (`blog-app.masonry`)
|
||||
```
|
||||
page Home at "/" layout public {
|
||||
title "Welcome to My Blog"
|
||||
section hero type container {
|
||||
component banner for Post
|
||||
}
|
||||
}
|
||||
|
||||
page AdminDashboard at "/dashboard" layout admin auth {
|
||||
title "Admin Dashboard"
|
||||
section stats type container {
|
||||
component table for Post
|
||||
}
|
||||
}
|
||||
|
||||
entity Post {
|
||||
title: string required
|
||||
content: text required
|
||||
published: boolean default "false"
|
||||
}
|
||||
|
||||
endpoint GET "/api/posts" for Post {
|
||||
returns Post as "json"
|
||||
}
|
||||
|
||||
endpoint POST "/api/posts" for Post auth {
|
||||
param title: string required from body
|
||||
param content: string required from body
|
||||
returns Post as "json"
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
./masonry.exe generate-multi --input blog-app.masonry --template-dir templates/nextjs-app --manifest manifest.yaml --output-dir ./my-blog-app
|
||||
```
|
||||
|
||||
This would generate:
|
||||
- `pages/index.tsx` (Home page)
|
||||
- `pages/admin/admindashboard.tsx` (Admin page)
|
||||
- `pages/api/posts.ts` (GET endpoint)
|
||||
- `pages/api/posts.ts` (POST endpoint - you'd need to handle method routing)
|
||||
- `lib/models/post.ts` (Post model)
|
||||
|
||||
This approach gives you the flexibility to generate files per AST item while still supporting single-file generation and complex conditional logic based on item attributes.
|
Reference in New Issue
Block a user