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 (
{{.AST.UIs.0.Name}}
{{- range .AST.UIs.0.Fields}}
{{.Name}}: {{{.Name}}}
{{- end}}
);
};
```
**`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 (
{{.Page.Name}}
{{if .Page.Auth}}
{/* Protected content */}
{{end}}
{{range .Page.Elements}}
{{if .Section}}
{{if .Section.Label}}{{.Section.Label | derefString}}
{{end}}
{{range .Section.Elements}}
{{if .Component}}
{/* Component: {{.Component.Type}} */}
{{end}}
{{end}}
{{else if .Component}}
{/* Component: {{.Component.Type}} */}
{{end}}
{{end}}
);
}
```
**`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.