implement a multi-file template interpreter
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||
|
@ -11,6 +11,7 @@ func main() {
|
||||
commands := []*cli.Command{
|
||||
createCmd(),
|
||||
generateCmd(),
|
||||
generateMultiCmd(), // New command for multi-file template generation
|
||||
webappCmd(),
|
||||
tailwindCmd(),
|
||||
setupCmd(),
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
92
examples/react-app-generator/blog-app.masonry
Normal file
92
examples/react-app-generator/blog-app.masonry
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
57
examples/react-app-generator/readme.md
Normal file
57
examples/react-app-generator/readme.md
Normal 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
|
54
examples/react-app-generator/templates/api-base.tmpl
Normal file
54
examples/react-app-generator/templates/api-base.tmpl
Normal 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);
|
||||
}
|
||||
}
|
33
examples/react-app-generator/templates/api-client.tmpl
Normal file
33
examples/react-app-generator/templates/api-client.tmpl
Normal 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}`);
|
||||
},
|
||||
};
|
13
examples/react-app-generator/templates/app-component.tmpl
Normal file
13
examples/react-app-generator/templates/app-component.tmpl
Normal 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;
|
46
examples/react-app-generator/templates/index-html.tmpl
Normal file
46
examples/react-app-generator/templates/index-html.tmpl
Normal 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>
|
14
examples/react-app-generator/templates/index.tmpl
Normal file
14
examples/react-app-generator/templates/index.tmpl
Normal 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>
|
||||
);
|
67
examples/react-app-generator/templates/manifest.yaml
Normal file
67
examples/react-app-generator/templates/manifest.yaml
Normal 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"
|
45
examples/react-app-generator/templates/package-json.tmpl
Normal file
45
examples/react-app-generator/templates/package-json.tmpl
Normal 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"
|
||||
}
|
||||
}
|
||||
|
67
examples/react-app-generator/templates/page-admin.tmpl
Normal file
67
examples/react-app-generator/templates/page-admin.tmpl
Normal 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>
|
||||
);
|
||||
}
|
50
examples/react-app-generator/templates/page-public.tmpl
Normal file
50
examples/react-app-generator/templates/page-public.tmpl
Normal 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>
|
||||
);
|
||||
}
|
113
examples/react-app-generator/templates/react-component.tmpl
Normal file
113
examples/react-app-generator/templates/react-component.tmpl
Normal 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}}
|
||||
}
|
19
examples/react-app-generator/templates/react-router.tmpl
Normal file
19
examples/react-app-generator/templates/react-router.tmpl
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
27
examples/react-app-generator/templates/tsconfig.tmpl
Normal file
27
examples/react-app-generator/templates/tsconfig.tmpl
Normal 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"
|
||||
]
|
||||
}
|
||||
|
@ -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}}
|
12
examples/react-app-generator/test-simple.masonry
Normal file
12
examples/react-app-generator/test-simple.masonry
Normal 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
1
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
|
||||
)
|
||||
|
3
go.sum
3
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=
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
}
|
||||
|
253
interpreter/template_iterator_test.go
Normal file
253
interpreter/template_iterator_test.go
Normal 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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user