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

3
.gitignore vendored
View File

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

View File

@ -11,6 +11,7 @@ func main() {
commands := []*cli.Command{
createCmd(),
generateCmd(),
generateMultiCmd(), // New command for multi-file template generation
webappCmd(),
tailwindCmd(),
setupCmd(),

View File

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

View File

@ -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"
}
}
}

View File

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

View File

@ -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<T>(
method: string,
endpoint: string,
data?: any
): Promise<T> {
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);
}
}

View File

@ -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<void> => {
return apiRequest('DELETE', `/{{.Entity.Name | lower}}s/${id}`);
},
};

View File

@ -0,0 +1,13 @@
import React from 'react';
import AppRouter from './router';
// import './App.css';
function App() {
return (
<div className="App">
<AppRouter />
</div>
);
}
export default App;

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="{{range .AST.Definitions}}{{if .Page}}{{if .Page.Description}}{{.Page.Description | derefString}}{{else}}Web site created using Masonry{{end}}{{break}}{{end}}{{end}}"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>{{range .AST.Definitions}}{{if .Page}}{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}{{break}}{{else}}Masonry App{{end}}{{end}}</title>
<!-- Tailwind CSS for styling -->
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

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

View File

@ -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"
}
}

View File

@ -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 <div>Loading...</div>;
}
return (
<div className="min-h-screen bg-gray-100">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link to="/admin/dashboard" className="text-xl font-bold text-blue-600">
Admin Dashboard
</Link>
</div>
<nav className="flex space-x-8">
{{range .AST.Definitions}}{{if .Page}}{{if eq .Page.Layout "admin"}}<Link
to="{{.Page.Path}}"
className="text-gray-500 hover:text-gray-700 px-3 py-2 rounded-md text-sm font-medium"
>
{{.Page.Name}}
</Link>
{{end}}{{end}}{{end}}
<button className="text-red-600 hover:text-red-700 px-3 py-2 rounded-md text-sm font-medium">
Logout
</button>
</nav>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<h1 className="text-3xl font-bold text-gray-900 mb-6">
{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}
</h1>
{{range .Page.Sections}}
<{{.Name | title}}Section />
{{end}}
{{range .Page.Components}}
<{{.Type | title}}Component />
{{end}}
</div>
</main>
</div>
);
}

View File

@ -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 (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link to="/" className="text-xl font-bold text-gray-900">
My Blog
</Link>
</div>
<nav className="flex space-x-8">
{{range .AST.Definitions}}{{if .Page}}{{if ne .Page.Layout "admin"}}<Link
to="{{.Page.Path}}"
className="text-gray-500 hover:text-gray-700 px-3 py-2 rounded-md text-sm font-medium"
>
{{.Page.Name}}
</Link>
{{end}}{{end}}{{end}}
</nav>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<h1 className="text-3xl font-bold text-gray-900 mb-6">
{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}
</h1>
{{range .Page.Sections}}
<{{.Name | title}}Section />
{{end}}
{{range .Page.Components}}
<{{.Type | title}}Component />
{{end}}
</div>
</main>
</div>
);
}

View File

@ -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 (
<div className={`bg-white p-6 rounded-lg shadow ${className}`}>
<form onSubmit={handleSubmit} className="space-y-4">
{{range .Component.Elements}}{{if .Field}}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{{.Field.Name | title}}
</label>
<input
type="{{if eq .Field.Type "text"}}text{{else if eq .Field.Type "email"}}email{{else}}text{{end}}"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setFormData({...formData, {{.Field.Name}}: e.target.value})}
/>
</div>
{{end}}{{end}}
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Submit
</button>
</form>
</div>
);
{{else if eq .Component.Type "table"}}
return (
<div className={`bg-white rounded-lg shadow overflow-hidden ${className}`}>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{{range .Component.Elements}}{{if .Field}}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{.Field.Name | title}}
</th>
{{end}}{{end}}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data?.map((item, index) => (
<tr key={index}>
{{range .Component.Elements}}{{if .Field}}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.{{.Field.Name}}}
</td>
{{end}}{{end}}
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button className="text-blue-600 hover:text-blue-900 mr-2">
Edit
</button>
<button className="text-red-600 hover:text-red-900">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
{{else if eq .Component.Type "list"}}
return (
<div className={`space-y-4 ${className}`}>
{data?.map((item, index) => (
<div key={index} className="bg-white p-6 rounded-lg shadow">
{{range .Component.Elements}}{{if .Field}}
<div className="mb-2">
<span className="font-semibold text-gray-700">{{.Field.Name | title}}:</span>
<span className="ml-2 text-gray-900">{item.{{.Field.Name}}}</span>
</div>
{{end}}{{end}}
</div>
))}
</div>
);
{{else}}
return (
<div className={`bg-white p-6 rounded-lg shadow ${className}`}>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{{.Component.Type | title}} Component
</h3>
<p className="text-gray-600">
This is a {{.Component.Type}} component. Add your custom implementation here.
</p>
</div>
);
{{end}}
}

View File

@ -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 (
<Router>
<Routes>
{{range .AST.Definitions}}{{if .Page}}
<Route
path="{{.Page.Path}}"
element={<{{.Page.Name}}Page />}
/>
{{end}}{{end}}
</Routes>
</Router>
);
}

View File

@ -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 (
<section className={`{{if .Section.Type}}{{.Section.Type}}{{else}}container{{end}} ${className}`}>
{{if .Section.Label}}<h2 className="text-2xl font-bold mb-4">{{.Section.Label | derefString}}</h2>
{{end}}
<div className="space-y-6">
{{range .Section.Elements}}
{{if .Component}}
<{{.Component.Type | title}}Component />
{{end}}
{{if .Section}}
<{{.Section.Name | title}}Section />
{{end}}
{{end}}
</div>
</section>
);
}

View File

@ -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"
]
}

View File

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

View File

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

1
go.mod
View File

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

3
go.sum
View File

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

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"),
},
},
},
},
},
}
}