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

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