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