refactor template functions to remove duplication

update readme TODOs
update plan for multi-file outputs
This commit is contained in:
2025-09-04 23:53:16 -06:00
parent 29314de3dd
commit 0bccd28134
3 changed files with 332 additions and 123 deletions

View File

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

View File

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

View File

@ -366,4 +366,321 @@ 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.
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.