implement a multi-file template interpreter

This commit is contained in:
2025-09-05 01:46:26 -06:00
parent 0bccd28134
commit 88d757546a
25 changed files with 1525 additions and 1 deletions

View File

@ -692,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

@ -13,8 +13,25 @@ import (
"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"`
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
@ -96,6 +113,354 @@ func (ti *TemplateInterpreter) Interpret(masonryInput string, tmplText string) (
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 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 {
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 directly in the page
for i := range def.Page.Sections {
sections = append(sections, &def.Page.Sections[i])
}
// 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{} {
var result []interface{}
for i := range sections {
for j := range sections[i].Elements {
element := &sections[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})...)
}
}
}
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 directly in the page
for i := range def.Page.Components {
components = append(components, &def.Page.Components[i])
}
// 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{} {
var components []interface{}
for i := range sections {
for j := range sections[i].Elements {
element := &sections[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})...)
}
}
}
return components
}
// getComponentsFromComponents recursively extracts components from nested components
func (ti *TemplateInterpreter) getComponentsFromComponents(components []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})...)
}
}
}
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
@ -121,6 +486,8 @@ func NewTemplateRegistry() *TemplateRegistry {
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",
@ -224,6 +591,24 @@ func NewTemplateRegistry() *TemplateRegistry {
}
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
}

View File

@ -0,0 +1,253 @@
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",
Components: []lang.Component{
{Type: "header"},
{Type: "footer"},
},
Sections: []lang.Section{
{
Name: "hero",
Type: strPtr("container"),
Elements: []lang.SectionElement{
{
Component: &lang.Component{
Type: "carousel",
},
},
{
Section: &lang.Section{
Name: "banner",
Type: strPtr("panel"),
},
},
},
},
{
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",
Sections: []lang.Section{
{
Name: "dashboard",
Type: strPtr("container"),
},
},
},
},
},
}
}