diff --git a/.gitignore b/.gitignore index 583bf28..109924b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /masonry.exe /.idea/copilotDiffState.xml +/examples/react-app-generator/generated-blog-app/node_modules/ +/examples/react-app-generator/generated-app/node_modules/ +/examples/react-app-generator/generated-app/ diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 3290de0..77143d0 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -11,6 +11,7 @@ func main() { commands := []*cli.Command{ createCmd(), generateCmd(), + generateMultiCmd(), // New command for multi-file template generation webappCmd(), tailwindCmd(), setupCmd(), diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 9cc5fdd..6a77739 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -664,3 +664,133 @@ func templateCmd() *cli.Command { }, } } + +func generateMultiCmd() *cli.Command { + return &cli.Command{ + Name: "generate-multi", + Aliases: []string{"gen-multi"}, + Usage: "Generate multiple files using a template manifest", + Description: "This command parses a Masonry file and applies a template manifest to generate multiple output files with per-item iteration support.", + Category: "generator", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "input", + Usage: "Path to the Masonry input file", + Required: true, + Aliases: []string{"i"}, + }, + &cli.StringFlag{ + Name: "template-dir", + Usage: "Path to the directory containing template files", + Required: true, + Aliases: []string{"d"}, + }, + &cli.StringFlag{ + Name: "manifest", + Usage: "Name of the manifest file (defaults to manifest.yaml)", + Value: "manifest.yaml", + Aliases: []string{"m"}, + }, + &cli.StringFlag{ + Name: "output-dir", + Usage: "Output directory for generated files (defaults to ./generated)", + Value: "./generated", + Aliases: []string{"o"}, + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "Show what files would be generated without writing them", + Value: false, + }, + &cli.BoolFlag{ + Name: "clean", + Usage: "Clean output directory before generating files", + Value: false, + }, + }, + Action: func(c *cli.Context) error { + inputFile := c.String("input") + templateDir := c.String("template-dir") + manifestFile := c.String("manifest") + outputDir := c.String("output-dir") + dryRun := c.Bool("dry-run") + clean := c.Bool("clean") + + // Validate input files exist + if _, err := os.Stat(inputFile); os.IsNotExist(err) { + return fmt.Errorf("input file does not exist: %s", inputFile) + } + if _, err := os.Stat(templateDir); os.IsNotExist(err) { + return fmt.Errorf("template directory does not exist: %s", templateDir) + } + manifestPath := filepath.Join(templateDir, manifestFile) + if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + return fmt.Errorf("manifest file does not exist: %s", manifestPath) + } + + fmt.Printf("Processing multi-file generation...\n") + fmt.Printf("Input: %s\n", inputFile) + fmt.Printf("Template Dir: %s\n", templateDir) + fmt.Printf("Manifest: %s\n", manifestFile) + fmt.Printf("Output Dir: %s\n", outputDir) + + // Clean output directory if requested + if clean && !dryRun { + if _, err := os.Stat(outputDir); !os.IsNotExist(err) { + fmt.Printf("Cleaning output directory: %s\n", outputDir) + err := os.RemoveAll(outputDir) + if err != nil { + return fmt.Errorf("error cleaning output directory: %w", err) + } + } + } + + // Create template interpreter + templateInterpreter := interpreter.NewTemplateInterpreter() + + // Generate multiple files using manifest + outputs, err := templateInterpreter.InterpretToFiles(inputFile, templateDir, manifestFile) + if err != nil { + return fmt.Errorf("error generating files: %w", err) + } + + if len(outputs) == 0 { + fmt.Println("No files were generated (check your manifest conditions)") + return nil + } + + fmt.Printf("Generated %d file(s):\n", len(outputs)) + + if dryRun { + // Dry run - just show what would be generated + for filePath, content := range outputs { + fmt.Printf(" [DRY-RUN] %s (%d bytes)\n", filePath, len(content)) + } + return nil + } + + // 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) + } + + fmt.Printf("Successfully generated %d file(s) in %s\n", len(outputs), outputDir) + return nil + }, + } +} diff --git a/examples/react-app-generator/blog-app.masonry b/examples/react-app-generator/blog-app.masonry new file mode 100644 index 0000000..437a8eb --- /dev/null +++ b/examples/react-app-generator/blog-app.masonry @@ -0,0 +1,92 @@ +// Example blog application with multiple pages, entities, and components +server api { + host "localhost" + port 3001 +} + +entity User { + name: string required + email: string required unique + role: string default "user" + created_at: timestamp +} + +entity Post { + title: string required + content: text required + published: boolean default "false" + author_id: string required + created_at: timestamp + updated_at: timestamp +} + +endpoint GET "/api/users" for User auth { + returns User as "json" +} + +endpoint POST "/api/users" for User { + param name: string required from body + param email: string required from body + returns User as "json" +} + +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" +} + +page Home at "/" layout public title "My Blog" desc "A simple blog built with Masonry" { + meta description "A simple blog built with Masonry" + + section hero type container { + component banner + } + + section content type container { + component list for Post { + fields [title, content, created_at] + } + + section sidebar type panel { + component widget { + data from "/api/recent-posts" + } + } + } + + component footer +} + +page Dashboard at "/admin/dashboard" layout admin title "Admin Dashboard" auth { + section stats type container { + component table for User { + fields [name, email, role, created_at] + actions [edit, delete] + } + } + + section management type container { + component form for Post { + fields [title, content, published] + validate + } + + component table for Post { + fields [title, published, created_at] + actions [edit, delete, publish] + } + } +} + +page About at "/about" layout public title "About Us" { + section info type container { + component text { + data from "/api/about-content" + } + } +} diff --git a/examples/react-app-generator/readme.md b/examples/react-app-generator/readme.md new file mode 100644 index 0000000..1ddc5ab --- /dev/null +++ b/examples/react-app-generator/readme.md @@ -0,0 +1,57 @@ +# React App Generator Example + +This example demonstrates how to use Masonry's multi-file template generation to create a complete React application with components, pages, and API integration. + +## What This Example Does + +This template generator creates: +- **React Pages** - One file per page definition (public and admin layouts) +- **React Components** - Individual component files for each component/section +- **API Client** - Generated API functions for each entity +- **Type Definitions** - TypeScript interfaces for entities +- **Routing** - React Router setup based on page definitions + +## Files Generated + +The manifest defines outputs that generate: +- `src/pages/` - Page components based on layout type +- `src/components/` - Individual component files +- `src/api/` - API client functions per entity +- `src/types/` - TypeScript interfaces per entity +- `src/router.tsx` - React Router configuration +- `package.json` - Project dependencies + +## How to Use + +1. **Create a Masonry file** (see `blog-app.masonry` example) +2. **Run the generator**: + ```bash + ../../masonry.exe generate-multi \ + --input blog-app.masonry \ + --template-dir templates \ + --manifest manifest.yaml \ + --output-dir ./generated-app + ``` +3. **Install dependencies and run**: + ```bash + cd generated-app + npm install + npm start + ``` + +## Template Features Demonstrated + +- **Per-item iteration** - Generate one file per page, entity, component +- **Conditional generation** - Different templates for admin vs public layouts +- **Dynamic paths** - File paths based on item properties +- **Nested template composition** - Components using other templates +- **Template functions** - String manipulation, type conversion + +## Extending This Example + +You can extend this template by: +- Adding more layout types (mobile, desktop, etc.) +- Creating specialized component templates (forms, tables, etc.) +- Adding test file generation +- Including CSS module generation +- Adding Docker/deployment configurations diff --git a/examples/react-app-generator/templates/api-base.tmpl b/examples/react-app-generator/templates/api-base.tmpl new file mode 100644 index 0000000..b598b19 --- /dev/null +++ b/examples/react-app-generator/templates/api-base.tmpl @@ -0,0 +1,54 @@ +// Base API client configuration +{{range .AST.Definitions}}{{if .Server}}const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://{{.Server.Settings | getHost}}:{{.Server.Settings | getPort}}'; +{{end}}{{end}} + +export interface ApiError { + message: string; + status: number; +} + +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + this.name = 'ApiError'; + } +} + +export async function apiRequest( + method: string, + endpoint: string, + data?: any +): Promise { + const config: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + }, + }; + + if (data) { + config.body = JSON.stringify(data); + } + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, config); + + if (!response.ok) { + throw new ApiError(`HTTP error! status: ${response.status}`, response.status); + } + + if (response.status === 204) { + return null as T; + } + + return await response.json(); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + throw new ApiError(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`, 0); + } +} diff --git a/examples/react-app-generator/templates/api-client.tmpl b/examples/react-app-generator/templates/api-client.tmpl new file mode 100644 index 0000000..2c4b3bb --- /dev/null +++ b/examples/react-app-generator/templates/api-client.tmpl @@ -0,0 +1,33 @@ +// API client for {{.Entity.Name}} +import { apiRequest } from './client'; + +export interface {{.Entity.Name}} { +{{range .Entity.Fields}} {{.Name}}: {{if eq .Type "string"}}string{{else if eq .Type "int"}}number{{else if eq .Type "boolean"}}boolean{{else if eq .Type "timestamp"}}Date{{else if eq .Type "text"}}string{{else}}any{{end}}; +{{end}}} + +export const {{.Entity.Name | lower}}Api = { + // Get all {{.Entity.Name | lower}}s + getAll: async (): Promise<{{.Entity.Name}}[]> => { + return apiRequest('GET', '/{{.Entity.Name | lower}}s'); + }, + + // Get {{.Entity.Name | lower}} by ID + getById: async (id: string): Promise<{{.Entity.Name}}> => { + return apiRequest('GET', `/{{.Entity.Name | lower}}s/${id}`); + }, + + // Create new {{.Entity.Name | lower}} + create: async (data: Omit<{{.Entity.Name}}, 'id' | 'created_at' | 'updated_at'>): Promise<{{.Entity.Name}}> => { + return apiRequest('POST', '/{{.Entity.Name | lower}}s', data); + }, + + // Update {{.Entity.Name | lower}} + update: async (id: string, data: Partial<{{.Entity.Name}}>): Promise<{{.Entity.Name}}> => { + return apiRequest('PUT', `/{{.Entity.Name | lower}}s/${id}`, data); + }, + + // Delete {{.Entity.Name | lower}} + delete: async (id: string): Promise => { + return apiRequest('DELETE', `/{{.Entity.Name | lower}}s/${id}`); + }, +}; diff --git a/examples/react-app-generator/templates/app-component.tmpl b/examples/react-app-generator/templates/app-component.tmpl new file mode 100644 index 0000000..20aec78 --- /dev/null +++ b/examples/react-app-generator/templates/app-component.tmpl @@ -0,0 +1,13 @@ +import React from 'react'; +import AppRouter from './router'; +// import './App.css'; + +function App() { + return ( +
+ +
+ ); +} + +export default App; diff --git a/examples/react-app-generator/templates/index-html.tmpl b/examples/react-app-generator/templates/index-html.tmpl new file mode 100644 index 0000000..238452b --- /dev/null +++ b/examples/react-app-generator/templates/index-html.tmpl @@ -0,0 +1,46 @@ + + + + + + + + + + + + + {{range .AST.Definitions}}{{if .Page}}{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}{{break}}{{else}}Masonry App{{end}}{{end}} + + + + + + +
+ + + diff --git a/examples/react-app-generator/templates/index.tmpl b/examples/react-app-generator/templates/index.tmpl new file mode 100644 index 0000000..cdd18ae --- /dev/null +++ b/examples/react-app-generator/templates/index.tmpl @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +// import './index.css'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); + +root.render( + + + +); diff --git a/examples/react-app-generator/templates/manifest.yaml b/examples/react-app-generator/templates/manifest.yaml new file mode 100644 index 0000000..78c25fd --- /dev/null +++ b/examples/react-app-generator/templates/manifest.yaml @@ -0,0 +1,67 @@ +name: "React App Generator" +description: "Generates a complete React application with pages, components, and API integration" +outputs: + # Generate public pages + - path: "src/pages/{{.Page.Name}}.tsx" + template: "page-public" + iterator: "pages" + item_context: "Page" + condition: "layout_public" + + # Generate admin pages + - path: "src/pages/admin/{{.Page.Name}}.tsx" + template: "page-admin" + iterator: "pages" + item_context: "Page" + condition: "layout_admin" + + # Generate component files for each section + - path: "src/components/sections/{{.Section.Name | title}}Section.tsx" + template: "section-component" + iterator: "sections" + item_context: "Section" + + # Generate component files for each component + - path: "src/components/{{.Component.Type | title}}Component.tsx" + template: "react-component" + iterator: "components" + item_context: "Component" + + # Generate API clients per entity + - path: "src/api/{{.Entity.Name | lower}}.ts" + template: "api-client" + iterator: "entities" + item_context: "Entity" + + # Generate TypeScript types per entity + - path: "src/types/{{.Entity.Name}}.ts" + template: "typescript-types" + iterator: "entities" + item_context: "Entity" + + # Single files - global configuration + - path: "src/router.tsx" + template: "react-router" + condition: "has_pages" + + - path: "src/api/client.ts" + template: "api-base" + condition: "has_entities" + + - path: "package.json" + template: "package-json" + + - path: "src/App.tsx" + template: "app-component" + condition: "has_pages" + + - path: "src/index.tsx" + template: "index" + + # Add the missing index.html file + - path: "public/index.html" + template: "index-html" + + # Add TypeScript configuration file + - path: "tsconfig.json" + template: "tsconfig" diff --git a/examples/react-app-generator/templates/package-json.tmpl b/examples/react-app-generator/templates/package-json.tmpl new file mode 100644 index 0000000..7040f27 --- /dev/null +++ b/examples/react-app-generator/templates/package-json.tmpl @@ -0,0 +1,45 @@ +{ + "name": "masonry-generated-blog-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.8.0", + "react-scripts": "5.0.1", + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "typescript": "^4.9.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/node": "^18.13.0", + "tailwindcss": "^3.2.4", + "autoprefixer": "^10.4.13", + "postcss": "^8.4.21" + } +} + diff --git a/examples/react-app-generator/templates/page-admin.tmpl b/examples/react-app-generator/templates/page-admin.tmpl new file mode 100644 index 0000000..ae06d25 --- /dev/null +++ b/examples/react-app-generator/templates/page-admin.tmpl @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +{{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}'; +{{end}}{{end}} +{{range .Page.Sections}}import {{.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Name | title}}Section'; +{{end}}{{range .Page.Components}}import {{.Type | title}}Component from '{{$relativePrefix}}components/{{.Type | title}}Component'; +{{end}} + +export default function {{.Page.Name}}Page() { + const navigate = useNavigate(); + const [user] = useState(null); // TODO: Implement actual auth + + // Redirect if not authenticated + React.useEffect(() => { + if (!user) { + navigate('/login'); + } + }, [user, navigate]); + + if (!user) { + return
Loading...
; + } + + return ( +
+
+
+
+
+ + Admin Dashboard + +
+ +
+
+
+ +
+
+

+ {{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}} +

+ + {{range .Page.Sections}} + <{{.Name | title}}Section /> + {{end}} + + {{range .Page.Components}} + <{{.Type | title}}Component /> + {{end}} +
+
+
+ ); +} diff --git a/examples/react-app-generator/templates/page-public.tmpl b/examples/react-app-generator/templates/page-public.tmpl new file mode 100644 index 0000000..c747e0e --- /dev/null +++ b/examples/react-app-generator/templates/page-public.tmpl @@ -0,0 +1,50 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +{{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}'; +{{end}}{{end}} +{{range .Page.Sections}}import {{.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Name | title}}Section'; +{{end}}{{range .Page.Components}}import {{.Type | title}}Component from '{{$relativePrefix}}components/{{.Type | title}}Component'; +{{end}} + +export default function {{.Page.Name}}Page() { + return ( +
+
+
+
+
+ + My Blog + +
+ +
+
+
+ +
+
+

+ {{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}} +

+ + {{range .Page.Sections}} + <{{.Name | title}}Section /> + {{end}} + + {{range .Page.Components}} + <{{.Type | title}}Component /> + {{end}} +
+
+
+ ); +} diff --git a/examples/react-app-generator/templates/react-component.tmpl b/examples/react-app-generator/templates/react-component.tmpl new file mode 100644 index 0000000..b45a54e --- /dev/null +++ b/examples/react-app-generator/templates/react-component.tmpl @@ -0,0 +1,113 @@ +import React from 'react'; +{{if .Component.Entity}}import { {{.Component.Entity}} } from '../types/{{.Component.Entity}}'; +{{end}} + +interface {{.Component.Type | title}}ComponentProps { + className?: string; + {{if .Component.Entity}}data?: {{.Component.Entity}}[]; + {{end}} +} + +export default function {{.Component.Type | title}}Component({ + className = '', + {{if .Component.Entity}}data{{end}} +}: {{.Component.Type | title}}ComponentProps) { + {{if eq .Component.Type "form"}} + const [formData, setFormData] = React.useState({}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + console.log('Form submitted:', formData); + // TODO: Implement form submission + }; + + return ( +
+
+ {{range .Component.Elements}}{{if .Field}} +
+ + setFormData({...formData, {{.Field.Name}}: e.target.value})} + /> +
+ {{end}}{{end}} + +
+
+ ); + {{else if eq .Component.Type "table"}} + return ( +
+ + + + {{range .Component.Elements}}{{if .Field}} + + {{end}}{{end}} + + + + + {data?.map((item, index) => ( + + {{range .Component.Elements}}{{if .Field}} + + {{end}}{{end}} + + + ))} + +
+ {{.Field.Name | title}} + + Actions +
+ {item.{{.Field.Name}}} + + + +
+
+ ); + {{else if eq .Component.Type "list"}} + return ( +
+ {data?.map((item, index) => ( +
+ {{range .Component.Elements}}{{if .Field}} +
+ {{.Field.Name | title}}: + {item.{{.Field.Name}}} +
+ {{end}}{{end}} +
+ ))} +
+ ); + {{else}} + return ( +
+

+ {{.Component.Type | title}} Component +

+

+ This is a {{.Component.Type}} component. Add your custom implementation here. +

+
+ ); + {{end}} +} diff --git a/examples/react-app-generator/templates/react-router.tmpl b/examples/react-app-generator/templates/react-router.tmpl new file mode 100644 index 0000000..d8a1341 --- /dev/null +++ b/examples/react-app-generator/templates/react-router.tmpl @@ -0,0 +1,19 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +{{range .AST.Definitions}}{{if .Page}}import {{.Page.Name}}Page from './pages/{{if eq .Page.Layout "admin"}}admin/{{end}}{{.Page.Name}}'; +{{end}}{{end}} + +export default function AppRouter() { + return ( + + + {{range .AST.Definitions}}{{if .Page}} + } + /> + {{end}}{{end}} + + + ); +} diff --git a/examples/react-app-generator/templates/section-component.tmpl b/examples/react-app-generator/templates/section-component.tmpl new file mode 100644 index 0000000..2b52a75 --- /dev/null +++ b/examples/react-app-generator/templates/section-component.tmpl @@ -0,0 +1,32 @@ +import React from 'react'; +{{if .Section.Entity}}import { {{.Section.Entity}} } from '../../types/{{.Section.Entity}}'; +{{end}}{{range .Section.Elements}}{{if .Section}}import {{.Section.Name | title}}Section from '../sections/{{.Section.Name | title}}Section';{{end}}{{if .Component}}import {{.Component.Type | title}}Component from '../{{.Component.Type | title}}Component';{{end}}{{end}} + +interface {{.Section.Name | title}}SectionProps { + className?: string; + {{if .Section.Entity}}data?: {{.Section.Entity}}[]; + {{end}} +} + +export default function {{.Section.Name | title}}Section({ + className = '' + {{if .Section.Entity}}, data{{end}} +}: {{.Section.Name | title}}SectionProps) { + return ( +
+ {{if .Section.Label}}

{{.Section.Label | derefString}}

+ {{end}} + +
+ {{range .Section.Elements}} + {{if .Component}} + <{{.Component.Type | title}}Component /> + {{end}} + {{if .Section}} + <{{.Section.Name | title}}Section /> + {{end}} + {{end}} +
+
+ ); +} diff --git a/examples/react-app-generator/templates/tsconfig.tmpl b/examples/react-app-generator/templates/tsconfig.tmpl new file mode 100644 index 0000000..1b8e6eb --- /dev/null +++ b/examples/react-app-generator/templates/tsconfig.tmpl @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "es6" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +} + diff --git a/examples/react-app-generator/templates/typescript-types.tmpl b/examples/react-app-generator/templates/typescript-types.tmpl new file mode 100644 index 0000000..a61f299 --- /dev/null +++ b/examples/react-app-generator/templates/typescript-types.tmpl @@ -0,0 +1,7 @@ +export interface {{.Entity.Name}} { +{{range .Entity.Fields}} {{.Name}}: {{if eq .Type "string"}}string{{else if eq .Type "int"}}number{{else if eq .Type "boolean"}}boolean{{else if eq .Type "timestamp"}}Date{{else if eq .Type "text"}}string{{else}}any{{end}}{{if .Required}} // Required{{end}}{{if .Default}} // Default: {{.Default | derefString}}{{end}}; +{{end}}} + +{{if .Entity.Fields}} +// Additional validation and utility types can be added here +{{end}} diff --git a/examples/react-app-generator/test-simple.masonry b/examples/react-app-generator/test-simple.masonry new file mode 100644 index 0000000..63e757d --- /dev/null +++ b/examples/react-app-generator/test-simple.masonry @@ -0,0 +1,12 @@ +// Simple test to verify parsing +server api { + host "localhost" + port 3001 +} + +entity User { + name: string required + email: string required +} + +page Home at "/" layout public diff --git a/go.mod b/go.mod index 3bab0aa..df2e321 100644 --- a/go.mod +++ b/go.mod @@ -12,4 +12,5 @@ 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 ) diff --git a/go.sum b/go.sum index 5cf5057..98f4d29 100644 --- a/go.sum +++ b/go.sum @@ -16,3 +16,6 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC 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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/html_interpreter.go b/interpreter/html_interpreter.go index 5ae5c9b..418194d 100644 --- a/interpreter/html_interpreter.go +++ b/interpreter/html_interpreter.go @@ -692,7 +692,7 @@ func (hi *HTMLInterpreter) generateFieldHTML(field *lang.ComponentField, indent default: html.WriteString(fmt.Sprintf("%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\n", indentStr)) diff --git a/interpreter/template_interpreter.go b/interpreter/template_interpreter.go index eb3ca60..b305ff7 100644 --- a/interpreter/template_interpreter.go +++ b/interpreter/template_interpreter.go @@ -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 := §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})...) + } + } + } + + 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 := §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})...) + } + } + } + + 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 } diff --git a/interpreter/template_iterator_test.go b/interpreter/template_iterator_test.go new file mode 100644 index 0000000..40203f1 --- /dev/null +++ b/interpreter/template_iterator_test.go @@ -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"), + }, + }, + }, + }, + }, + } +}