Skip to content

React Admin Site example

So you want to generate React admin sites from a DSL, not hand-craft each one. ProJor is perfect for that: you describe your models, data, and templates once, and ProJor turns that into a working codebase.

In this guide we’ll build a ProJor template repository that generates a React+TypeScript+Tailwind admin UI, including:

  • A React + TypeScript + Tailwind CSS admin site
  • Multiple page types: Dashboard, List, Edit
  • A small DSL to define pages
  • Layout components (header, sidebar, main area)
  • A basic widget system for dashboards
  • Documentation and Docker setup

We’ll start very simple and add complexity step by step, always keeping the same core idea:

You define the what in .projor/, ProJor generates the how in src/, public/, etc.

Click here to access the source code of the example.


Before We Start: Core ProJor Concepts

Let’s demystify some words we’ll keep using:

  • Schemas – Think of these as typed “classes” for your domain:

    • Example: Page, WidgetType, ListPageColumn.
    • Defined under .projor/schema/*.pschema.yaml.
  • Data collections – Concrete objects that follow a schema:

    • Example: PageType instances (Dashboard, ListPage, EditPage).
    • Defined under .projor/data/*.pdata.yaml.
  • Templates – Mustache-like files that generate code:

    • Example: package-json.ptemplate.mustachepackage.json.
    • Defined under .projor/template/.
  • Partials – Reusable template fragments:

    • Example: reusable JSX bits for dashboards or list pages.
    • Defined under .projor/partials/.
  • Globals – Project-wide configuration you can access everywhere:

    • Example: header/sidebar layout config, project name, etc.
    • Defined as *.pglobal.yaml in .projor/.
  • Languages – Custom DSL parsers:

    • Example: a parser that turns .pages files into Page objects.
    • Defined under .projor/language/*.plang.js.

We’ll wire all of these together into a single coherent template repo.


Step 1: Base Project Setup

Directory Layout

We start by creating a .projor/ folder that holds everything ProJor needs to know:

text
.projor/
├── schema/          # Data structure definitions (Page, DashboardPage, etc.)
├── data/            # Predefined data collections (PageType, WidgetType, etc.)
├── template/        # Code generation templates (TSX files, package.json, Dockerfile…)
├── partials/        # Reusable template bits (dashboard/list/edit content)
└── language/        # Custom DSL parsers (our .pages DSL)

Your actual app files (src/, public/, etc.) will be generated based on what’s inside .projor/.

Project Globals

Create .projor/project.pglobal.yaml:

yaml
scope: my-org
name: admin-site
description: My admin site
version: 0.0.1
initialPage: dashboard

These are “root” values accessible from any template:

  • $.scope
  • $.name
  • $.description
  • $.version
  • $.initialPage

We’ll use them for things like npm package name, initial route, and layout title.

First Template: package.json

Let’s generate a minimal package.json using ProJor.

Create .projor/template/package-json.ptemplate.mustache:

mustache
{
    "map": {},
    "filename": "package.json",
    "formatUsing": "json"
}
---
{
    "name": "{{#if $.scope}}@{{$.scope}}/{{/if}}{{kebabCase $.name}}",
    "version": "{{{$.version}}}",
    "description": "{{$.description}}",
    "private": true,
    "dependencies": {
        "react": "19.2.0",
        "react-dom": "19.2.0",
        "@iconify/react": "6.0.2"
    },
    "devDependencies": {
        "esbuild": "0.27.0",
        "typescript": "5.9.3",
        "@types/react": "19.2.5",
        "@types/react-dom": "19.2.3",
        "tailwindcss": "4.1.17",
        "@tailwindcss/cli": "4.1.17"
    },
    "scripts": {
        "build:css": "pnpx @tailwindcss/cli -i src/tailwind.css -o public/index.css --minify",
        "build:js": "esbuild src/index.tsx --bundle --outfile=public/index.js --minify --jsx=automatic",
        "build": "pnpm run build:css && pnpm run build:js"
    }
}

What’s going on here:

  • map: {} – We don’t need to iterate or join collections; we just generate a single file.

  • filename – The output path: package.json.

  • formatUsing: "json" – ProJor will pretty-format the JSON for us.

  • We use {{$.scope}}, {{$.name}}, {{$.version}}, {{$.description}} from project.pglobal.yaml.

  • We include dependencies for React, TypeScript, Tailwind, and Iconify.

  • We define build scripts for:

    • build:css – Tailwind CLI to generate public/index.css
    • build:jsesbuild bundling src/index.tsxpublic/index.js
    • build – Runs both of the above

Run:

bash
projor generate

You’ll get a ready-to-use package.json for the generated app.


Step 2: Page Types – Dashboard, List, Edit

We want different kinds of pages in our admin UI: dashboards, lists, and edit forms.

PageType Schema

Create .projor/schema/PageType.pschema.yaml:

yaml
id: PageType
name: Page Type
description: A type of page in the admin site
fields: []

This schema is intentionally empty: we only care that a PageType has a name and description (which come from data objects), not extra fields.

PageType Data Collection

Create .projor/data/pagetype.pdata.yaml:

yaml
id: pagetype
name: Page Types
description: The supported types of pages in the admin site
schema: PageType
objects:
  - name: Dashboard
    description: A dashboard page
  - name: ListPage
    description: A page that lists items
  - name: EditPage
    description: A page that edits an item

Now we have three page types we can refer to elsewhere via references to PageType.


Step 3: Basic Page Schema + A Tiny DSL

Page Schema

Create .projor/schema/Page.pschema.yaml:

yaml
id: Page
name: Page
description: A page in the admin site
fields:
  - name: type
    description: The type of the page
    type: reference
    references: PageType
    required: true
  - name: title
    description: The title of the page
    type: string
    required: true
  - name: message
    description: The message to display on the page
    type: string
    required: true
  - name: icon
    description: The icon to display on the page
    type: string
    required: true
  - name: dashboard
    type: attachment
    references: DashboardPage
  - name: listPage
    type: attachment
    references: ListPage
  - name: editPage
    type: attachment
    references: EditPage
  - name: unlisted
    type: boolean
    description: Whether to hide from navigation

Key ideas:

  • type: reference + references: PageType means every Page points to one of our PageType objects.
  • dashboard, listPage, editPage are attachments to more specific schemas (we’ll define those later).
  • unlisted will let us hide certain pages from sidebar navigation.

Our Page DSL: .projor/.pages

Instead of editing Page objects by hand, we’ll define them in a human-friendly DSL.

Create .projor/.pages:

text
dashboard Dashboard[ic:baseline-dashboard] {
    title Admin Dashboard
    message Welcome to the admin site
    stat widget Revenue[$1,000,000] { }
}

list_page Products[ic:baseline-shopping-cart] {
    title Products
    message List of all products
    column id: number example 123
    column name: string example Product Name
    column price: number example 99.99
    item action Edit[ic:baseline-edit] goes to EditProduct
    item action Delete[ic:baseline-delete] goes to Products
}

edit_page EditProduct[ic:baseline-edit]<unlisted> {
    title Edit Product
    message Edit the product details
    field name: string example Product Name
    field price: number example 99.99
    item action Save[ic:baseline-save] goes to Products
    item action Cancel[ic:baseline-cancel] goes to Products
}

The DSL is intentionally lightweight:

  • dashboard, list_page, edit_page indicate the page type.
  • Name[icon-id] sets the page name and icon.
  • { ... } contains fields like title, message, and type-specific stuff (widgets, columns, fields).
  • <unlisted> marks a page that should not appear in the sidebar.

The DSL Parser

We need to turn .pages into a ProJor collection of Page objects.

Create .projor/language/pages.plang.js:

javascript
function extractField(body, fieldName) {
    const regex = new RegExp(fieldName + '\\s+([^\\n]+)');
    const match = body.match(regex);
    return match ? match[1].trim() : '';
}

async function parseAllPagesFiles(files) {
    const result = {
        schema: "Page",
        objects: []
    };

    for (const file of files) {
        const content = file.content;

        // DASHBOARD PAGES
        const dashboardRegex = /dashboard\s+(\w+)\[([^\]]+)\](\<unlisted\>)?\s*\{([^}]+)\}/gs;
        let match;

        while ((match = dashboardRegex.exec(content)) !== null) {
            const [, name, icon, unlistedFlag, body] = match;

            const page = {
                name: name,
                type: "pagetype#Dashboard",
                title: extractField(body, "title"),
                message: extractField(body, "message"),
                icon: icon.trim(),
                unlisted: !!unlistedFlag,
                dashboard: {
                    widgets: [],
                    preActions: [],
                    postActions: []
                }
            };

            const statRegex = /stat widget\s+([^\[]+)\[([^\]]+)\]/g;
            let statMatch;
            while ((statMatch = statRegex.exec(body)) !== null) {
                page.dashboard.widgets.push({
                    type: "widgettype#Stat",
                    statWidget: {
                        label: statMatch[1].trim(),
                        value: statMatch[2].trim()
                    }
                });
            }

            // Pre/post actions can be added here similarly if needed
            result.objects.push(page);
        }

        // LIST PAGES
        const listPageRegex = /list_page\s+(\w+)\[([^\]]+)\](\<unlisted\>)?\s*\{([^}]+)\}/gs;
        while ((match = listPageRegex.exec(content)) !== null) {
            const [, name, icon, unlistedFlag, body] = match;

            const page = {
                name: name,
                type: "pagetype#ListPage",
                title: extractField(body, "title"),
                message: extractField(body, "message"),
                icon: icon.trim(),
                unlisted: !!unlistedFlag,
                listPage: {
                    columns: [],
                    itemActions: [],
                    preActions: [],
                    postActions: []
                }
            };

            const columnRegex = /column\s+(\w+):\s*(\w+)\s+example\s+([^\n]+)/g;
            let columnMatch;
            while ((columnMatch = columnRegex.exec(body)) !== null) {
                page.listPage.columns.push({
                    name: columnMatch[1],
                    type: `basic#${columnMatch[2]}`,
                    example: columnMatch[3].trim()
                });
            }

            const actionRegex = /item action\s+([^\[]+)\[([^\]]+)\]\s+goes to\s+(\w+)/g;
            let actionMatch;
            while ((actionMatch = actionRegex.exec(body)) !== null) {
                page.listPage.itemActions.push({
                    name: actionMatch[1].trim(),
                    icon: actionMatch[2].trim(),
                    goesTo: `pages#${actionMatch[3]}`
                });
            }

            // Optional: pre/post actions using `pre action`/`post action` syntax
            result.objects.push(page);
        }

        // EDIT PAGES
        const editPageRegex = /edit_page\s+(\w+)\[([^\]]+)\](\<unlisted\>)?\s*\{([^}]+)\}/gs;
        while ((match = editPageRegex.exec(content)) !== null) {
            const [, name, icon, unlistedFlag, body] = match;

            const page = {
                name: name,
                type: "pagetype#EditPage",
                title: extractField(body, "title"),
                message: extractField(body, "message"),
                icon: icon.trim(),
                unlisted: !!unlistedFlag,
                editPage: {
                    fields: [],
                    postActions: [],
                    preActions: []
                }
            };

            const fieldRegex = /field\s+(\w+):\s*(\w+)\s+example\s+([^\n]+)/g;
            let fieldMatch;
            while ((fieldMatch = fieldRegex.exec(body)) !== null) {
                page.editPage.fields.push({
                    name: fieldMatch[1],
                    type: `basic#${fieldMatch[2]}`,
                    example: fieldMatch[3].trim()
                });
            }

            const actionRegex = /item action\s+([^\[]+)\[([^\]]+)\]\s+goes to\s+(\w+)/g;
            let actionMatch;
            while ((actionMatch = actionRegex.exec(body)) !== null) {
                page.editPage.postActions.push({
                    name: actionMatch[1].trim(),
                    icon: actionMatch[2].trim(),
                    goesTo: `pages#${actionMatch[3]}`
                });
            }

            result.objects.push(page);
        }
    }

    return result;
}

async function parse(files) {
    return await parseAllPagesFiles(files);
}

module.exports = { parse };

You don’t need to love regex to use this; you just need to know it turns our DSL into objects of schema Page.


Step 4: Generating Page Components & Entry Point

Page Components (src/pages/*.page.tsx)

Each Page becomes a React component. We’ll refine this later with layouts and content partials, but let’s start simple.

Create .projor/template/page-tsx.ptemplate.mustache:

mustache
{
    "forEach": "pages",
    "filename": "src/pages/{{kebabCase name}}.page.tsx",
    "formatUsing": "typescript"
}
---
import { AppLayout } from "../layout/AppLayout.component";
import { AppDashboardStat } from "../components/AppDashboardStat.component";
import { AppAction } from "../components/AppAction.component";

{{#if dashboard}}
{{> dashboard-parts page=this }}
{{/if}}

{{#if listPage}}
{{> list-page-parts page=this }}
{{/if}}

{{#if editPage}}
{{> edit-page-parts page=this }}
{{/if}}

export function {{pascalCase name}}Page(opts: { navigate: (page: string) => void }) {
    return (
        <AppLayout 
            navigate={opts.navigate}
            content={() => (
                <div className="p-6 space-y-4">
                    <h1 className="text-2xl font-bold">{{title}}</h1>
                    <p className="text-gray-600">{{message}}</p>

                    {{#if dashboard}}
                    <{{pascalCase name}}Content />
                    {{/if}}

                    {{#if listPage}}
                    <{{pascalCase name}}Content navigate={opts.navigate} />
                    {{/if}}

                    {{#if editPage}}
                    <{{pascalCase name}}Content navigate={opts.navigate} />
                    {{/if}}
                </div>
            )}
        />
    );
}
  • forEach: "pages" – one file per Page.
  • We import AppLayout and, depending on page type, use partials for content.
  • Every page receives a navigate(page: string) function so it can trigger navigation via actions.

Entry Point: src/index.tsx

We now tie all pages together and implement a tiny “router” based on state.

Create .projor/template/index-tsx.ptemplate.mustache:

mustache
{
    "map": { "p": "pages" },
    "filename": "src/index.tsx",
    "formatUsing": "typescript"
}
---
import { createRoot } from "react-dom/client";
import { useState } from "react";

{{#each p}}
import { {{pascalCase name}}Page } from "./pages/{{kebabCase name}}.page";
{{/each}}

function App() {
    const [currentPage, setCurrentPage] = useState("{{kebabCase $.initialPage}}");

    const navigate = (page: string) => setCurrentPage(page);

    return (
        <div className="min-h-screen">
            {{#each p}}
            {currentPage === "{{kebabCase name}}" && (
                <{{pascalCase name}}Page navigate={navigate} />
            )}
            {{/each}}
        </div>
    );
}

const root = createRoot(document.getElementById("root")!);
root.render(<App />);

This is intentionally simple:

  • Internal state currentPage
  • navigate() changes it
  • Pages render conditionally based on currentPage

You can swap this out later for React Router; the template is decoupled from the concept.


Step 5: Layout Components & Globals

We don’t want each page to re-build header and sidebar. Let’s generate a reusable layout.

Layout Globals

Create .projor/header.pglobal.yaml:

yaml
header:
  enabled: true
  background: slate-800
  text: white

Create .projor/sidebar.pglobal.yaml:

yaml
sidebar:
  enabled: true
  background: slate-900
  text: slate-100
  width: 64

These will control whether header/sidebar show up and their basic Tailwind colors/dimensions.

App Layout Component

Create .projor/template/app-layout-tsx.ptemplate.mustache:

mustache
{
    "map": { "pages": "pages" },
    "filename": "src/layout/AppLayout.component.tsx",
    "formatUsing": "typescript"
}
---
import { ReactNode } from "react";

{{#if $.header.enabled}}
function AppHeader() {
    return (
        <header className="bg-{{$.header.background}} text-{{$.header.text}} p-4">
            <h1 className="text-2xl font-bold">{{capitalCase $.name}}</h1>
        </header>
    );
}
{{/if}}

{{#if $.sidebar.enabled}}
function AppSidebar(opts: { navigate: (page: string) => void }) {
    return (
        <aside className="w-{{$.sidebar.width}} bg-{{$.sidebar.background}} text-{{$.sidebar.text}} p-4">
            <nav className="space-y-2">
                {{#each pages}}
                {{#unless unlisted}}
                <button
                    className="block w-full text-left hover:underline"
                    onClick={() => opts.navigate("{{kebabCase name}}")}
                >
                    {{capitalCase name}}
                </button>
                {{/unless}}
                {{/each}}
            </nav>
        </aside>
    );
}
{{/if}}

export function AppLayout(opts: {
    navigate: (page: string) => void;
    content: () => ReactNode;
}) {
    return (
        <div className="flex flex-col min-h-screen">
            {{#if $.header.enabled}}
            <AppHeader />
            {{/if}}

            <div className="flex flex-1">
                {{#if $.sidebar.enabled}}
                <AppSidebar navigate={opts.navigate} />
                {{/if}}

                <main className="flex-1">
                    {opts.content()}
                </main>
            </div>
        </div>
    );
}

Highlights:

  • Layout is controlled purely via globals ($.header, $.sidebar).
  • Sidebar is auto-generated from pages, but hides pages with unlisted: true.
  • All pages share the same layout via AppLayout.

Step 6: Dashboard Pages & Widgets

Now we want dashboards with widgets (e.g., stat tiles, lists, messages).

DashboardPage Schema

Create .projor/schema/DashboardPage.pschema.yaml:

yaml
id: DashboardPage
name: Dashboard Page
description: A dashboard page
fields:
  - name: widgets
    description: Widgets to display on the dashboard
    type: attachment
    references: DashboardWidget
    multiple: true
  - name: preActions
    type: attachment
    references: Action
    multiple: true
  - name: postActions
    type: attachment
    references: Action
    multiple: true

WidgetType Schema & Data

Create .projor/schema/WidgetType.pschema.yaml:

yaml
id: WidgetType
name: Widget Type
description: A type of widget
fields: []

Create .projor/data/widgettype.pdata.yaml:

yaml
id: widgettype
name: Widget Types
schema: WidgetType
objects:
  - name: Stat
    description: A statistic widget
  - name: List
    description: A list widget
  - name: Message
    description: A message widget

DashboardWidget Schema

Create .projor/schema/DashboardWidget.pschema.yaml:

yaml
id: DashboardWidget
name: Dashboard Widget
description: A widget on the dashboard
fields:
  - name: type
    type: reference
    references: WidgetType
    required: true
  - name: statWidget
    type: attachment
    references: StatWidget
  - name: listWidget
    type: attachment
    references: ListWidget
  - name: messageWidget
    type: attachment
    references: MessageWidget

Specific Widget Schemas

Create .projor/schema/StatWidget.pschema.yaml:

yaml
id: StatWidget
name: Stat Widget
description: A statistic display widget
fields:
  - name: label
    type: string
    description: The label for the statistic
  - name: value
    type: string
    description: The value to display

Create .projor/schema/ListWidget.pschema.yaml:

yaml
id: ListWidget
name: List Widget
description: A list widget
fields:
  - name: items
    type: attachment
    references: ListWidgetItem
    multiple: true

Create .projor/schema/ListWidgetItem.pschema.yaml:

yaml
id: ListWidgetItem
name: List Widget Item
fields: []

Dashboard Stat Component

Create .projor/template/app-dashboard-stat-tsx.ptemplate.mustache:

mustache
{
    "map": {},
    "filename": "src/components/AppDashboardStat.component.tsx",
    "formatUsing": "typescript"
}
---
export function AppDashboardStat(props: {
    label: string;
    value: string;
}) {
    return (
        <div className="bg-white p-6 rounded-lg shadow">
            <div className="text-sm text-gray-600">{props.label}</div>
            <div className="text-3xl font-bold">{props.value}</div>
        </div>
    );
}

Dashboard Content Partial

Create .projor/partials/dashboard-parts.partial.mustache:

mustache
function {{pascalCase page.name}}Content() {
    return (
        <div className="grid grid-cols-3 gap-4">
            {{#each page.dashboard.widgets}}
            {{#if statWidget}}
            <AppDashboardStat 
                label="{{statWidget.label}}" 
                value="{{statWidget.value}}" 
            />
            {{/if}}
            {{/each}}
        </div>
    );
}

This partial is included in the page-tsx template (we already wired it up earlier). It knows how to render Stat widgets. You can add other widget types later using the same structure.


Step 7: List Pages

We now define list pages: basically tables with columns and example rows.

Basic Types

Create .projor/schema/BasicType.pschema.yaml:

yaml
id: BasicType
name: Basic Type
description: A basic data type
fields:
  - name: ts
    type: string
    required: true

Create .projor/data/basic.pdata.yaml:

yaml
id: basic
name: Basic Types
schema: BasicType
objects:
  - name: string
    ts: string
  - name: number
    ts: number
  - name: boolean
    ts: boolean

These let us refer to basic types (string, number, boolean) from other schemas.

ListPage & ListPageColumn Schemas

Create .projor/schema/ListPage.pschema.yaml:

yaml
id: ListPage
name: List Page
description: A list page showing a table of items
fields:
  - name: columns
    type: attachment
    references: ListPageColumn
    multiple: true
  - name: itemActions
    type: attachment
    references: Action
    multiple: true
  - name: preActions
    type: attachment
    references: Action
    multiple: true
  - name: postActions
    type: attachment
    references: Action
    multiple: true

Create .projor/schema/ListPageColumn.pschema.yaml:

yaml
id: ListPageColumn
name: List Page Column
description: A column in a list page
fields:
  - name: type
    type: reference
    references: BasicType
    required: true
  - name: example
    type: string

We already updated Page.pschema.yaml earlier to add listPage.

List Page Content Partial

Create .projor/partials/list-page-parts.partial.mustache:

mustache
function {{pascalCase page.name}}Content(opts: { navigate: (page: string) => void }) {
    return (
        <div className="space-y-4">
            <div className="flex gap-2">
                {{#each page.listPage.preActions}}
                <AppAction
                    label="{{name}}"
                    icon="{{icon}}"
                    onClick={() => opts.navigate("{{kebabCase goesTo.name}}")}
                />
                {{/each}}
            </div>

            <table className="w-full border-collapse">
                <thead>
                    <tr>
                        {{#each page.listPage.columns}}
                        <th className="border-b py-2 text-left">{{capitalCase name}}</th>
                        {{/each}}
                        {{#if page.listPage.itemActions.length}}
                        <th className="border-b py-2 text-left">Actions</th>
                        {{/if}}
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        {{#each page.listPage.columns}}
                        <td className="border-b py-2">{{example}}</td>
                        {{/each}}
                        {{#if page.listPage.itemActions.length}}
                        <td className="border-b py-2">
                            <div className="flex gap-2">
                                {{#each page.listPage.itemActions}}
                                <AppAction
                                    label="{{name}}"
                                    icon="{{icon}}"
                                    onClick={() => opts.navigate("{{kebabCase goesTo.name}}")}
                                />
                                {{/each}}
                            </div>
                        </td>
                        {{/if}}
                    </tr>
                </tbody>
            </table>

            <div className="flex gap-2">
                {{#each page.listPage.postActions}}
                <AppAction
                    label="{{name}}"
                    icon="{{icon}}"
                    onClick={() => opts.navigate("{{kebabCase goesTo.name}}")}
                />
                {{/each}}
            </div>
        </div>
    );
}

Now your list pages show:

  • Optional pre-actions above the table
  • A table with example data
  • Per-row item actions
  • Optional post-actions below the table

Step 8: Actions and Navigation

We want structured “buttons” that can navigate between pages.

Action Schema

Create .projor/schema/Action.pschema.yaml:

yaml
id: Action
name: Action
description: An action button
fields:
  - name: goesTo
    type: reference
    references: Page
    required: true
  - name: icon
    type: string
    required: true

We already hooked actions into DashboardPage, ListPage, and EditPage schemas (pre/post/item actions).

Actions in DSL

Example inside .projor/.pages (already shown earlier):

text
list_page Products[ic:baseline-shopping-cart] {
    title Products
    message List of all products
    column id: number example 123
    column name: string example Product Name
    column price: number example 99.99
    item action Edit[ic:baseline-edit] goes to EditProduct
    item action Delete[ic:baseline-delete] goes to Products
}

…and for edit page:

text
edit_page EditProduct[ic:baseline-edit]<unlisted> {
    title Edit Product
    message Edit the product details
    field name: string example Product Name
    field price: number example 99.99
    item action Save[ic:baseline-save] goes to Products
    item action Cancel[ic:baseline-cancel] goes to Products
}

The parser maps those to Action objects referencing the corresponding Page.

Action Component

Create .projor/template/app-action-tsx.ptemplate.mustache:

mustache
{
    "map": {},
    "filename": "src/components/AppAction.component.tsx",
    "formatUsing": "typescript"
}
---
import { Icon } from "@iconify/react";

export function AppAction(props: {
    label: string;
    icon: string;
    onClick: () => void;
}) {
    return (
        <button 
            onClick={props.onClick}
            className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
        >
            <Icon icon={props.icon} />
            {props.label}
        </button>
    );
}

This is a generic “button with icon” we reuse everywhere for actions.


Step 9: Edit Pages

Edit pages display a form with fields and “Save/Cancel” style actions.

EditPage Schemas

Create .projor/schema/EditPage.pschema.yaml:

yaml
id: EditPage
name: Edit Page
description: An edit page for editing items
fields:
  - name: fields
    type: attachment
    references: EditPageField
    multiple: true
  - name: postActions
    type: attachment
    references: Action
    multiple: true
  - name: preActions
    type: attachment
    references: Action
    multiple: true

Create .projor/schema/EditPageField.pschema.yaml:

yaml
id: EditPageField
name: Edit Page Field
description: A field in an edit form
fields:
  - name: type
    type: reference
    references: BasicType
    required: true
  - name: example
    type: string

We’ve already used these in the parser and Page schema.

Edit Page Content Partial

Create .projor/partials/edit-page-parts.partial.mustache:

mustache
function {{pascalCase page.name}}Content(opts: { navigate: (page: string) => void }) {
    return (
        <form className="space-y-4">
            {{#each page.editPage.fields}}
            <div>
                <label className="block text-sm font-medium mb-1">
                    {{capitalCase name}}
                </label>
                <input 
                    type="{{#equal type.name "number"}}number{{else}}text{{/equal}}"
                    defaultValue="{{example}}"
                    className="mt-1 block w-full rounded border-gray-300 px-3 py-2"
                />
            </div>
            {{/each}}

            <div className="flex gap-2">
                {{#each page.editPage.postActions}}
                <AppAction 
                    label="{{name}}"
                    icon="{{icon}}"
                    onClick={() => opts.navigate("{{kebabCase goesTo.name}}")}
                />
                {{/each}}
            </div>
        </form>
    );
}
  • Field type is chosen based on BasicType (number<input type="number">, everything else → text).
  • Actions call navigate() with the target page’s kebab-case name.

Step 10: Tailwind CSS Wiring

We already used Tailwind classes in components; now we make Tailwind build work.

Tailwind Config Template

Create .projor/template/tailwind-config-js.ptemplate.mustache:

mustache
{
    "map": {},
    "filename": "tailwind.config.js",
    "formatUsing": "javascript"
}
---
export default {
    content: [
        "./src/**/*.{ts,tsx}"
    ]
};

Tailwind Entry CSS

Create .projor/template/tailwind-css.ptemplate.mustache:

mustache
{
    "map": {},
    "filename": "src/tailwind.css"
}
---
@import "tailwindcss";

Combined with the package.json scripts we already defined, this gives you:

  • pnpm run build:css → builds public/index.css using Tailwind CLI
  • pnpm run build:js → builds public/index.js via esbuild
  • pnpm run build → both of the above

Step 11: Documentation Generation

We don’t just want code – we want docs that explain the generated app itself.

README Template

Create .projor/template/readme-md.ptemplate.mustache:

mustache
{
    "map": { "p": "pages" },
    "filename": "README.md"
}
---
# {{capitalCase $.name}}

{{$.description}}

## Pages

This application contains {{p.length}} pages:

{{#each p}}
### {{capitalCase name}}

{{#unless unlisted}}
**Navigation:** Available in sidebar
{{else}}
**Navigation:** Unlisted (accessed via links)
{{/unless}}

{{#if dashboard}}
**Type:** Dashboard

Widgets:
{{#each dashboard.widgets}}
- {{#if statWidget}}Stat: {{statWidget.label}}{{/if}}
{{#if listWidget}}List: {{name}}{{/if}}
{{/each}}
{{/if}}

{{#if listPage}}
**Type:** List Page

Columns:
{{#each listPage.columns}}
- {{name}} ({{type.name}})
{{/each}}
{{/if}}

{{#if editPage}}
**Type:** Edit Page

Fields:
{{#each editPage.fields}}
- {{name}} ({{type.name}})
{{/each}}
{{/if}}

{{/each}}

## Development

```bash
pnpm install
pnpm run build
```

This README is derived purely from the model and stays in sync with your pages.

Pages Reference

Create .projor/template/pages-md.ptemplate.mustache:

mustache
{
    "map": { "p": "pages" },
    "filename": "PAGES.md"
}
---
# Pages Reference

{{#each p}}
## {{capitalCase name}}

- **Title:** {{title}}
- **Message:** {{message}}
- **Icon:** {{icon}}
- **Type:** {{type.name}}
{{#if unlisted}}
- **Unlisted:** Yes
{{/if}}

{{/each}}

This gives you a quick technical overview: name, title, message, icon, type, and unlisted flag.


Step 12: Docker Support

We’ll also generate Docker files so you can run the built app behind Nginx.

Dockerfile Template

Create .projor/template/dockerfile.ptemplate.mustache:

mustache
{
    "map": {},
    "filename": "Dockerfile"
}
---
FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install

COPY . .
RUN pnpm run build

FROM nginx:alpine

COPY --from=builder /app/public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

docker-compose Template

Create .projor/template/docker-compose-yaml.ptemplate.mustache:

mustache
{
    "map": {},
    "filename": "docker-compose.yaml"
}
---
services:
  web:
    build: .
    ports:
      - "8080:80"
    environment:
      - NODE_ENV=production

You can add the nginx.conf file separately (not part of this template), and you’ll have a one-command way to run the static built app.


Step 13: Advanced Features – Unlisted & Action Positioning

We’ve already wired most of this, but let’s recap the two advanced concepts.

Unlisted Pages

  • Page schema has a boolean unlisted field.

  • In .pages, you can mark a page as unlisted using <unlisted>:

    text
    edit_page EditProduct[ic:baseline-edit]<unlisted> {
        ...
    }
  • The parser detects <unlisted> and sets page.unlisted = true.

  • The sidebar template (AppSidebar) uses {{#unless unlisted}} to hide such pages from navigation.

This is useful for pages reachable only via deep links or actions, not the main menu.

PreActions & PostActions

For DashboardPage, ListPage, and EditPage we added:

  • preActions – actions rendered before the main content
  • postActions – actions rendered after the main content

The DSL can support patterns like:

text
list_page Products[ic:baseline-shopping-cart] {
    title Products
    pre action Create[ic:baseline-add] goes to CreateProduct
    # ... columns ...
    post action Dashboard[ic:baseline-dashboard] goes to Dashboard
}

The parser maps those pre action / post action expressions into the corresponding arrays, and the partials (dashboard-parts, list-page-parts, edit-page-parts) render them appropriately.


Full Workflow: How You Actually Use This

  1. Define your pages in DSL

    Edit .projor/.pages and describe your dashboard, list, and edit pages:

    text
    dashboard Dashboard[ic:baseline-dashboard] {
        title My Dashboard
        message Welcome!
        stat widget Users[1,234] { }
    }
    
    list_page Items[ic:baseline-list] {
        title Items
        message All items
        column name: string example Example
    }
  2. Adjust layout & globals

    Tweak .projor/header.pglobal.yaml, .projor/sidebar.pglobal.yaml, and project.pglobal.yaml to match your app branding and layout preferences.

  3. Generate the code

    bash
    projor generate

    This produces:

    • src/index.tsx
    • src/layout/AppLayout.component.tsx
    • src/components/*
    • src/pages/*.page.tsx
    • tailwind.config.js
    • src/tailwind.css
    • package.json
    • README.md, PAGES.md
    • Dockerfile, docker-compose.yaml
  4. Build & run

    bash
    pnpm install
    pnpm run build

    Optionally, run via Docker:

    bash
    docker compose up --build

Conceptual Recap

Schema Design

  • Start with small, composable schemas:

    • PageType, BasicType, WidgetType.
  • Use references for relationships:

    • Page.type → PageType, ListPageColumn.type → BasicType, etc.
  • Use attachments for embedded structured data:

    • Page.dashboard → DashboardPage, DashboardPage.widgets → DashboardWidget.

Data Collections

  • Use .pdata.yaml files for fixed sets:

    • pagetype, widgettype, basic.
  • Reference them using type: reference, references: SchemaName.

Templates & Partials

  • Use forEach when generating many files (page-tsx).

  • Use map when generating a single file that needs access to collections (index-tsx, README).

  • Split complex JSX into partials:

    • dashboard-parts.partial.mustache
    • list-page-parts.partial.mustache
    • edit-page-parts.partial.mustache
  • Use formatUsing to keep code tidy: typescript, json, javascript.

DSL & Language

  • The .pages DSL is a thin, human-friendly layer:

    • dashboard, list_page, edit_page blocks.
    • title, message, column, field, stat widget, item action.
  • The .plang.js parser converts this DSL into strict ProJor objects.

  • Everything else (components, README, Dockerfile) is generated from that model.

Globals & Config

  • Project globals (project.pglobal.yaml) capture:

    • Naming, versioning, initial page, etc.
  • Layout globals (header.pglobal.yaml, sidebar.pglobal.yaml) capture:

    • Header/sidebar presence and styles.
  • All are accessible in templates as {{$.something}}.


Why This Repository Works Well

  1. Layered abstraction

    • Schemas → structure
    • Data → instances
    • DSL → human-friendly editing
    • Templates → output code
  2. Separation of concerns

    • Page structure vs layout vs widgets vs actions vs docs vs Docker are all separate concerns, yet tied together by the ProJor model.
  3. Configuration over code

    • Want to hide a page from navigation? Add <unlisted>, no TSX edits.
    • Want to change header color? Change header.pglobal.yaml.
  4. Incremental extension

    • Add a new widget type? Define schema, extend WidgetType data, add a component & partial.
    • Add a new page type? Add schema, add to PageType data, extend the parser and templates.
  5. Single source of truth

    • .projor/ is the brain.
    • The React app, documentation, and Docker setup are all projections of that.

Where to Go Next

From here, you can extend the template while keeping the same architecture:

  1. New page types

    • E.g., a “details” page or “wizard” page.
  2. More widget types

    • Charts, counters with trends, alerts.
  3. Authentication

    • Generate a login page and wire it into your layout.
  4. API integration

    • Generate API client code side-by-side with your UI pages.
  5. Styling & theming

    • Introduce themes via additional globals and Tailwind classes.

All of that can be layered on top of the structure you already have – no need to rewrite the generator, just keep evolving .projor/.