git update recorded at: 23/04/26 09:18:21

This commit is contained in:
Abhishek Test Tiwari 2026-04-23 09:18:21 +00:00
parent 027c3432cc
commit 0f88df0206
49 changed files with 4807 additions and 16 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
.git
.github
*.log
.DS_Store

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
node_modules
dist
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.DS_Store
node_modules/.tmp
*.tsbuildinfo

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
FROM node:lts-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install --no-audit --no-fund --include=dev
COPY . .
RUN npm run build
FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
RUN npm install -g serve@14 --no-audit --no-fund
COPY --from=build /app/dist ./dist
EXPOSE 8080
CMD ["sh", "-c", "serve -s dist -l $PORT"]

View File

@ -1,17 +1,9 @@
app: dashboard-test-app
app: renderer-bundle
region: asia-south1
build:
builtin: dockerfile
ignorefile: .gitignore
language: nodejs/18
region: asia-south1
serverlessConfig:
Scaling:
AutoStop: true
Min: 1
Max: 1
MaxIdleTime: 300
Resources:
CPU: 0.1
MemoryMB: 128
MemoryMaxMB: 128
DiskMB: 100
env:
PORT: "8080"

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>renderer-bundle</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "renderer-bundle",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "node ./node_modules/typescript/bin/tsc -b && node ./node_modules/vite/bin/vite.js build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.11.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"typescript": "~5.9.3",
"vite": "^7.2.4"
}
}

1
public/pagesData.json Normal file

File diff suppressed because one or more lines are too long

46
src/App.tsx Normal file
View File

@ -0,0 +1,46 @@
import React from 'react';
import { RendererApp } from './renderer';
export function App() {
const [json, setJson] = React.useState<string>('');
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
fetch('/pagesData.json')
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to load pagesData.json (${response.status})`);
}
return response.text();
})
.then((rawJson) => {
let normalized = rawJson;
try {
const parsed = JSON.parse(rawJson) as { state?: unknown };
if (parsed?.state && typeof parsed.state === 'object' && !Array.isArray(parsed.state)) {
normalized = JSON.stringify(parsed.state);
}
} catch {
normalized = rawJson;
}
setJson(normalized);
})
.catch((fetchError) => {
setError(fetchError instanceof Error ? fetchError.message : String(fetchError));
});
}, []);
if (error) {
return <div style={{ padding: 16 }}>Error: {error}</div>;
}
if (!json) {
return <div style={{ padding: 16 }}>Loading renderer JSON\u2026</div>;
}
return (
<div style={{ height: '100vh' }}>
<RendererApp json={json} />
</div>
);
}

View File

@ -0,0 +1,424 @@
import type { EventHandler, Page } from '../types/component.types';
import { evaluateExpression } from '../utils/expressionEvaluator';
import { resolveRequestBody } from '../utils/stateBinding';
import { normalizeWorkflowResultKey, suggestWorkflowResultKeyFromName } from '../utils/workflowResultKey';
import { parseScopedPath } from '../state/scopedPath';
/**
* Event types that components can dispatch
*/
export type EventType = 'click' | 'change' | 'submit' | 'focus' | 'blur';
/**
* Action types that event handlers can execute
*/
export type ActionType = 'setState' | 'navigateToPage' | 'workflow';
/**
* Context required for executing actions
*/
export interface ActionContext {
/** Set a state value by path */
setStateValue: (path: string, value: any) => boolean;
/** Set a state value by raw key */
setStateValueRaw: (key: string, value: any) => boolean;
/** Set a state value directly without type auto-detection (for expressions) */
setStateValueDirect: (path: string, value: any) => boolean;
/** Navigate to a page by ID */
navigateToPage: (pageId: string) => void;
/** All pages (for validation) */
pages: Page[];
/** Scoped runtime state (for expression evaluation) */
state: any;
}
/**
* Configuration for workflow execution
*/
export interface WorkflowConfig {
/** URL to call for workflow execution */
executeUrl: string;
/** Request body builder */
buildRequestBody?: (workflowName: string) => any;
}
/**
* Action executor function type
*/
export type ActionExecutor = (
handler: EventHandler,
context: ActionContext,
config: EventExecutorConfig
) => Promise<void>;
/**
* Configuration for the EventExecutor
*/
export interface EventExecutorConfig {
workflow?: WorkflowConfig;
}
/**
* Validates a single event handler structure
*/
const isValidHandler = (handler: any): handler is EventHandler => {
if (!handler || typeof handler !== 'object') return false;
if (!handler.id || typeof handler.id !== 'string') return false;
if (!handler.action || !['setState', 'navigateToPage', 'workflow'].includes(handler.action)) return false;
if (handler.action === 'setState') {
return !!(handler.statePath && handler.value !== undefined);
} else if (handler.action === 'navigateToPage') {
return !!handler.pageId;
} else if (handler.action === 'workflow') {
return !!handler.workflowName;
}
return false;
};
/**
* Validates and filters event handlers array
*/
export const validateHandlers = (handlers: any[]): EventHandler[] => {
if (!Array.isArray(handlers)) return [];
return handlers.filter(isValidHandler);
};
// ============= Action Executors =============
/**
* Execute setState action
*/
const executeSetState: ActionExecutor = async (handler, context) => {
if (!handler.statePath || typeof handler.statePath !== 'string') {
throw new Error(`Invalid state path for handler ${handler.id}`);
}
let parsedStatePath;
try {
parsedStatePath = parseScopedPath(handler.statePath);
} catch (error) {
throw new Error(
`Invalid scoped state path for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`
);
}
// Evaluate expression if value is a string (JavaScript expression)
let evaluatedValue = handler.value;
if (typeof handler.value === 'string' && handler.value.trim().length > 0) {
try {
evaluatedValue = evaluateExpression(handler.value, { state: context.state });
} catch (error: any) {
throw new Error(`Expression evaluation failed for handler ${handler.id}: ${error?.message || String(error)}`);
}
}
// Use setStateValueDirect to preserve expression result type
// This prevents "1" from being auto-converted to 1, which would break string methods like .slice()
const success = context.setStateValueDirect(parsedStatePath.fullPath, evaluatedValue);
if (!success) {
throw new Error(`Failed to set state value for handler ${handler.id}`);
}
};
/**
* Execute navigateToPage action
*/
const executeNavigateToPage: ActionExecutor = async (handler, context) => {
if (!handler.pageId) {
throw new Error(`Missing pageId for handler ${handler.id}`);
}
const pageExists = context.pages.some((p) => p.id === handler.pageId);
if (!pageExists) {
throw new Error(`Page ${handler.pageId} does not exist for handler ${handler.id}`);
}
context.navigateToPage(handler.pageId);
};
/**
* Execute workflow action
*/
const executeWorkflow: ActionExecutor = async (handler, context, config) => {
if (!handler.workflowName || typeof handler.workflowName !== 'string') {
throw new Error(`Missing workflow name for handler ${handler.id}`);
}
// Use handler's workflowUrl if available, otherwise fall back to config
const workflowUrl = handler.workflowUrl || config.workflow?.executeUrl;
if (!workflowUrl) {
throw new Error(
`Workflow handler ${handler.id} requires a workflow URL. Provide handler.workflowUrl or configure workflow.executeUrl.`
);
}
// Resolve request body with state bindings
let requestBody: any = {};
if (handler.requestBody) {
// Use handler's custom request body and resolve {{ }} bindings
try {
requestBody = resolveRequestBody(handler.requestBody, context.state);
} catch (error) {
throw new Error(`Failed to resolve request body for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
const buildRequestBody = config.workflow?.buildRequestBody;
if (buildRequestBody) {
// Fallback to config's buildRequestBody (for backward compatibility)
requestBody = buildRequestBody(handler.workflowName);
}
}
const response = await fetch(workflowUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Workflow execution failed: ${response.status}`);
}
const responseText = await response.text();
let workflowResult: any = null;
if (responseText) {
try {
workflowResult = JSON.parse(responseText);
} catch {
workflowResult = responseText;
}
}
let normalizedResultKey;
try {
const resultKeyInput = handler.workflowResultKey?.trim()
? handler.workflowResultKey
: suggestWorkflowResultKeyFromName(handler.workflowName);
normalizedResultKey = normalizeWorkflowResultKey(resultKeyInput);
} catch (error) {
throw new Error(
`Invalid workflow result key for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`
);
}
const success = context.setStateValueDirect(normalizedResultKey.fullPath, workflowResult);
if (!success) {
throw new Error(`Failed to save workflow result for handler ${handler.id}`);
}
};
// ============= Action Registry =============
/**
* Registry of action executors
*/
const actionExecutors: Map<ActionType, ActionExecutor> = new Map([
['setState', executeSetState],
['navigateToPage', executeNavigateToPage],
['workflow', executeWorkflow],
]);
/**
* Register a custom action executor
*/
export const registerActionExecutor = (action: string, executor: ActionExecutor): void => {
actionExecutors.set(action as ActionType, executor);
};
/**
* Get an action executor by action type
*/
export const getActionExecutor = (action: ActionType): ActionExecutor | undefined => {
return actionExecutors.get(action);
};
// ============= EventExecutor Class =============
/**
* EventExecutor - Centralized service for executing event handlers
*/
export class EventExecutor {
private config: EventExecutorConfig;
private context: ActionContext | null = null;
constructor(config?: Partial<EventExecutorConfig>) {
this.config = {
workflow: config?.workflow,
};
}
/**
* Set the action context (called when context changes)
*/
setContext(context: ActionContext): void {
this.context = context;
}
/**
* Evaluate a handler's condition (if present)
* @param handler - Event handler with optional condition
* @returns true if condition passes or no condition exists, false otherwise
* @throws Error if condition evaluation fails with error message
*/
private evaluateCondition(handler: EventHandler): boolean {
// If no condition specified, always execute (backward compatibility)
if (!handler.condition || handler.condition.trim() === '') {
return true;
}
if (!this.context) {
return false;
}
try {
// Evaluate the condition expression
const result = evaluateExpression(handler.condition, { state: this.context.state });
// If condition is falsy, check if we should show an error
if (!result) {
if (handler.conditionErrorMessage) {
// Throw error with custom message - will be caught by executeWithErrorHandling
throw new Error(handler.conditionErrorMessage);
}
// No error message - silently skip this handler
return false;
}
return true;
} catch (error: any) {
// If there's a custom error message from condition failure, use it
if (handler.conditionErrorMessage && error.message === handler.conditionErrorMessage) {
throw error; // Re-throw to preserve custom message
}
// Otherwise, it's an expression evaluation error
throw new Error(`Condition evaluation failed for handler ${handler.id}: ${error?.message || String(error)}`);
}
}
/**
* Execute event handlers sequentially
* @param handlers - Array of event handlers to execute
* @throws Error if execution fails
*/
async execute(handlers: EventHandler[]): Promise<void> {
if (!this.context) {
throw new Error('EventExecutor context not set. Call setContext() first.');
}
const validHandlers = validateHandlers(handlers);
if (validHandlers.length === 0) return;
for (const handler of validHandlers) {
// Evaluate condition before executing handler
const shouldExecute = this.evaluateCondition(handler);
// Skip this handler if condition failed (without error message)
if (!shouldExecute) {
console.log(`Skipping handler ${handler.id} - condition not met`);
continue;
}
const executor = actionExecutors.get(handler.action);
if (!executor) {
throw new Error(`Unknown action type: ${handler.action}`);
}
await executor(handler, this.context, this.config);
}
}
/**
* Execute handlers with error handling (shows alert on failure)
*/
async executeWithErrorHandling(handlers: EventHandler[]): Promise<boolean> {
try {
await this.execute(handlers);
return true;
} catch (error) {
console.error('Event execution failed:', error);
const message = error instanceof Error && error.message
? error.message
: 'Event handler execution failed. Please check your workflow or state configuration.';
alert(message);
return false;
}
}
}
// ============= Singleton Instance =============
/**
* Global EventExecutor instance
*/
export const eventExecutor = new EventExecutor();
// ============= Legacy API (backward compatibility) =============
/**
* Legacy function for backward compatibility
* @deprecated Use eventExecutor.execute() instead
*/
export const executeEventHandlers = async (
handlers: EventHandler[],
context: ActionContext & { runWorkflow?: (workflowName: string) => Promise<any> }
): Promise<void> => {
const validHandlers = validateHandlers(handlers);
if (validHandlers.length === 0) return;
for (const handler of validHandlers) {
if (handler.action === 'setState') {
if (!handler.statePath || typeof handler.statePath !== 'string') {
throw new Error(`Invalid state path for handler ${handler.id}`);
}
let parsedStatePath;
try {
parsedStatePath = parseScopedPath(handler.statePath);
} catch (error) {
throw new Error(
`Invalid scoped state path for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`
);
}
const success = context.setStateValue(parsedStatePath.fullPath, handler.value);
if (!success) {
throw new Error(`Failed to set state value for handler ${handler.id}`);
}
} else if (handler.action === 'navigateToPage') {
if (!handler.pageId) {
throw new Error(`Missing pageId for handler ${handler.id}`);
}
const pageExists = context.pages.some((p) => p.id === handler.pageId);
if (!pageExists) {
throw new Error(`Page ${handler.pageId} does not exist for handler ${handler.id}`);
}
context.navigateToPage(handler.pageId);
} else if (handler.action === 'workflow') {
if (!handler.workflowName || typeof handler.workflowName !== 'string') {
throw new Error(`Missing workflow name for handler ${handler.id}`);
}
if (context.runWorkflow) {
const workflowResult = await context.runWorkflow(handler.workflowName);
let normalizedResultKey;
try {
const resultKeyInput = handler.workflowResultKey?.trim()
? handler.workflowResultKey
: suggestWorkflowResultKeyFromName(handler.workflowName);
normalizedResultKey = normalizeWorkflowResultKey(resultKeyInput);
} catch (error) {
throw new Error(
`Invalid workflow result key for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`
);
}
const success = context.setStateValueDirect(normalizedResultKey.fullPath, workflowResult);
if (!success) {
throw new Error(`Failed to save workflow result for handler ${handler.id}`);
}
}
}
}
};

View File

@ -0,0 +1,36 @@
/**
* Events Module
*
* Centralized event system for component event handling.
*
* Usage:
* 1. Import the eventExecutor singleton:
* import { eventExecutor } from './events';
*
* 2. Set context when runtime initializes:
* eventExecutor.setContext({ setStateValue, navigateToPage, pages });
*
* 3. Execute handlers:
* await eventExecutor.execute(handlers);
*
* 4. Register custom action executors:
* registerActionExecutor('customAction', async (handler, context) => { ... });
*/
export {
EventExecutor,
eventExecutor,
registerActionExecutor,
getActionExecutor,
validateHandlers,
executeEventHandlers,
} from './EventExecutor';
export type {
EventType,
ActionType,
ActionContext,
ActionExecutor,
WorkflowConfig,
EventExecutorConfig,
} from './EventExecutor';

View File

@ -0,0 +1,96 @@
import React from 'react';
interface IconProps {
size?: number;
className?: string;
style?: React.CSSProperties;
}
export const BoxIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="4" y="4" width="16" height="16" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
</svg>
);
export const ColumnIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="4" y="4" width="16" height="5" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
<rect x="4" y="10" width="16" height="5" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
<rect x="4" y="16" width="16" height="4" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
</svg>
);
export const RowIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="4" y="4" width="5" height="16" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
<rect x="10" y="4" width="5" height="16" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
<rect x="16" y="4" width="4" height="16" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
</svg>
);
export const ButtonIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="4" y="8" width="16" height="8" stroke="currentColor" strokeWidth="1.5" fill="none" rx="4" />
</svg>
);
export const TextIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<path d="M4 6h16M12 6v12M8 18h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
export const GridIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="4" y="4" width="7" height="7" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
<rect x="13" y="4" width="7" height="7" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
<rect x="4" y="13" width="7" height="7" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
<rect x="13" y="13" width="7" height="7" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
</svg>
);
export const ListIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<circle cx="6" cy="6" r="1.5" fill="currentColor" />
<line x1="10" y1="6" x2="20" y2="6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="6" cy="12" r="1.5" fill="currentColor" />
<line x1="10" y1="12" x2="20" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="6" cy="18" r="1.5" fill="currentColor" />
<line x1="10" y1="18" x2="20" y2="18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
export const InputIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="4" y="8" width="16" height="8" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
<line x1="7" y1="12" x2="7" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
export const MobileIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="7" y="3" width="10" height="18" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
<line x1="10" y1="18" x2="14" y2="18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
export const TabletIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="4" y="3" width="16" height="18" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
<line x1="10" y1="18" x2="14" y2="18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
export const DesktopIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<rect x="2" y="4" width="20" height="13" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
<line x1="9" y1="21" x2="15" y2="21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
export const PlusIcon: React.FC<IconProps> = ({ size = 16, style }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
<path d="M12 5V19M5 12H19" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);

View File

@ -0,0 +1,113 @@
import type { CSSProperties } from 'react';
import type { ComponentDefinition } from './types';
/**
* Centralized registry for all component definitions.
* Singleton pattern ensures a single source of truth.
*/
class ComponentRegistryClass {
private definitions: Map<string, ComponentDefinition> = new Map();
/**
* Register a component definition
* @param definition The component definition to register
* @throws Error if component type is already registered
*/
register(definition: ComponentDefinition): void {
if (this.definitions.has(definition.type)) {
console.warn(`Component type "${definition.type}" is already registered. Overwriting.`);
}
this.definitions.set(definition.type, definition);
}
/**
* Get a component definition by type
* @param type The component type to look up
* @returns The component definition or undefined if not found
*/
get(type: string): ComponentDefinition | undefined {
return this.definitions.get(type);
}
/**
* Check if a component type is registered
* @param type The component type to check
*/
has(type: string): boolean {
return this.definitions.has(type);
}
/**
* Get all registered component definitions
* @returns Array of all component definitions
*/
getAll(): ComponentDefinition[] {
return Array.from(this.definitions.values());
}
/**
* Get component definitions filtered by category
* @param category 'container' or 'leaf'
*/
getByCategory(category: 'container' | 'leaf'): ComponentDefinition[] {
return this.getAll().filter((def) => def.category === category);
}
/**
* Get default styles for a component type
* @param type The component type
* @returns Default styles or empty object if not found
*/
getDefaultStyles(type: string): CSSProperties {
const definition = this.get(type);
return definition?.defaultStyles ?? {};
}
/**
* Get default props for a component type
* @param type The component type
* @returns Default props or empty object if not found
*/
getDefaultProps(type: string): Record<string, any> {
const definition = this.get(type);
return definition?.defaultProps ?? {};
}
/**
* Check if a component type can have children
* @param type The component type
* @returns true if component can have children, false otherwise
*/
canHaveChildren(type: string): boolean {
const definition = this.get(type);
return definition?.canHaveChildren ?? false;
}
/**
* Get icon component for a component type
* @param type The component type
* @returns Icon component or undefined if not found
*/
getIcon(type: string): React.ComponentType<any> | undefined {
const definition = this.get(type);
return definition?.icon;
}
/**
* Get all registered component types
* @returns Array of component type strings
*/
getTypes(): string[] {
return Array.from(this.definitions.keys());
}
/**
* Clear all registered components (useful for testing)
*/
clear(): void {
this.definitions.clear();
}
}
// Export singleton instance
export const ComponentRegistry = new ComponentRegistryClass();

View File

@ -0,0 +1,172 @@
import React from 'react';
import { BoxIcon } from '../../icons/ComponentIcons';
import type { ComponentDefinition } from '../types';
export const boxDefinition: ComponentDefinition = {
type: 'box',
label: 'Box',
icon: BoxIcon,
category: 'container',
canHaveChildren: true,
defaultStyles: {
width: 'auto',
display: 'block',
},
defaultProps: {},
propertySchema: [],
styleSchema: [
// Layout
{ key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
{ key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
{ key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
{ key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
{ key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
{ key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
{
key: 'display',
label: 'Display',
type: 'select',
category: 'layout',
options: [
{ value: 'block', label: 'Block' },
{ value: 'flex', label: 'Flex' },
{ value: 'grid', label: 'Grid' },
{ value: 'inline', label: 'Inline' },
{ value: 'inline-block', label: 'Inline Block' },
{ value: 'none', label: 'None' },
],
},
{
key: 'alignSelf',
label: 'Self Align',
type: 'select',
category: 'layout',
options: [
{ value: 'auto', label: 'Auto' },
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'baseline', label: 'Baseline' },
],
},
{
key: 'overflow',
label: 'Overflow',
type: 'select',
category: 'layout',
options: [
{ value: 'visible', label: 'Visible' },
{ value: 'hidden', label: 'Hidden' },
{ value: 'scroll', label: 'Scroll' },
{ value: 'auto', label: 'Auto' },
],
},
// Positioning
{
key: 'position',
label: 'Position',
type: 'select',
category: 'layout',
options: [
{ value: 'static', label: 'Static' },
{ value: 'relative', label: 'Relative' },
{ value: 'absolute', label: 'Absolute' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'sticky', label: 'Sticky' },
],
},
{ key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
{ key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
{ key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
{ key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
{ key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
// Spacing
{ key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
{ key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
// Appearance
{ key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
{ key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
// Border
{ key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
{
key: 'borderStyle',
label: 'Border Style',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'double', label: 'Double' },
],
},
{ key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
{ key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
{ key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
// Grid Item (when inside grid container)
{ key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
{ key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
{ key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
{ key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
{ key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
{ key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
{
key: 'justifySelf',
label: 'Justify Self',
type: 'select',
category: 'grid-item',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
],
render: ({ component, isSelected, onSelect, previewMode, children }) => {
const isEmpty = !component.children || component.children.length === 0;
return React.createElement(
'div',
{
style: {
...component.styles,
outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
minHeight: isEmpty && !previewMode ? '60px' : 'auto',
position: 'relative' as const,
},
onClick: previewMode
? undefined
: (e: React.MouseEvent) => {
e.stopPropagation();
onSelect(component.id);
},
},
children,
isEmpty && !previewMode
? React.createElement(
'div',
{
style: {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#999',
fontSize: '14px',
pointerEvents: 'none' as const,
},
},
'Drop components here'
)
: null
);
},
};

View File

@ -0,0 +1,263 @@
import React from 'react';
import { ButtonIcon } from '../../icons/ComponentIcons';
import type { ComponentDefinition, ComponentRenderProps } from '../types';
const ensureSpinnerStyles = () => {
if (typeof document === 'undefined') return;
if (document.getElementById('button-spinner-keyframes')) return;
const style = document.createElement('style');
style.id = 'button-spinner-keyframes';
style.textContent = `
@keyframes button-spin {
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
};
const ButtonComponent: React.FC<ComponentRenderProps> = ({ component, isSelected, onSelect, previewMode, runtime }) => {
const rawTextContent = component.props?.text || 'Button';
const textContent = runtime.resolveText(rawTextContent);
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
ensureSpinnerStyles();
}, []);
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (previewMode) {
if (isLoading) return;
setIsLoading(true);
try {
await runtime.dispatchEvent('click', component, 'eventHandlers');
} catch (error) {
console.error('Event execution failed:', error);
} finally {
setIsLoading(false);
}
return;
}
onSelect(component.id);
};
const spinner = React.createElement('span', {
'aria-hidden': true,
style: {
display: 'inline-block',
width: '12px',
height: '12px',
border: '2px solid currentColor',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'button-spin 0.6s linear infinite',
marginRight: '6px',
},
});
const loadingContent = React.createElement(
'span',
{ style: { display: 'inline-flex', alignItems: 'center' } },
spinner,
textContent
);
return React.createElement(
'button',
{
style: {
...component.styles,
outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
opacity: isLoading ? 0.7 : component.styles.opacity,
cursor: isLoading ? 'not-allowed' : (component.styles.cursor || 'pointer'),
},
onClick: handleClick,
disabled: isLoading,
'aria-busy': isLoading,
},
isLoading ? loadingContent : textContent
);
};
/**
* Button component - fully decoupled from stores
* Uses runtime.resolveText() for text bindings
* Uses runtime.dispatchEvent() for event handling
*/
export const buttonDefinition: ComponentDefinition = {
type: 'button',
label: 'Button',
icon: ButtonIcon,
category: 'leaf',
canHaveChildren: false,
defaultStyles: {
padding: '8px 16px',
backgroundColor: 'rgb(255, 89, 47)',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
width: 'auto',
alignSelf: 'auto',
},
defaultProps: {
text: 'Button',
eventHandlers: [],
},
propertySchema: [
{ key: 'text', label: 'Text', type: 'text-binding', category: 'content' },
{ key: 'eventHandlers', label: 'Event Handlers', type: 'custom', customEditor: 'eventHandlers', category: 'events' },
],
styleSchema: [
// Layout
{ key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
{ key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
{ key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
{ key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
{
key: 'alignSelf',
label: 'Self Align',
type: 'select',
category: 'layout',
options: [
{ value: 'auto', label: 'Auto' },
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'baseline', label: 'Baseline' },
],
},
// Positioning
{
key: 'position',
label: 'Position',
type: 'select',
category: 'layout',
options: [
{ value: 'static', label: 'Static' },
{ value: 'relative', label: 'Relative' },
{ value: 'absolute', label: 'Absolute' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'sticky', label: 'Sticky' },
],
},
{ key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
{ key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
{ key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
{ key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
{ key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
// Spacing
{ key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
{ key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
// Appearance
{ key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
{ key: 'color', label: 'Text Color', type: 'color', category: 'appearance' },
{ key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
// Typography
{ key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' },
{
key: 'fontFamily',
label: 'Font Family',
type: 'select',
category: 'appearance',
options: [
{ value: 'inherit', label: 'Inherit' },
{ value: 'Arial, sans-serif', label: 'Arial' },
{ value: 'Helvetica, sans-serif', label: 'Helvetica' },
{ value: 'Georgia, serif', label: 'Georgia' },
{ value: '"Times New Roman", serif', label: 'Times New Roman' },
{ value: '"Courier New", monospace', label: 'Courier New' },
{ value: 'Verdana, sans-serif', label: 'Verdana' },
{ value: 'system-ui, sans-serif', label: 'System UI' },
],
},
{
key: 'fontWeight',
label: 'Font Weight',
type: 'select',
category: 'appearance',
options: [
{ value: 'normal', label: 'Normal' },
{ value: 'bold', label: 'Bold' },
{ value: '100', label: '100 (Thin)' },
{ value: '200', label: '200' },
{ value: '300', label: '300 (Light)' },
{ value: '400', label: '400 (Normal)' },
{ value: '500', label: '500 (Medium)' },
{ value: '600', label: '600 (Semibold)' },
{ value: '700', label: '700 (Bold)' },
{ value: '800', label: '800' },
{ value: '900', label: '900 (Black)' },
],
},
{
key: 'textTransform',
label: 'Text Transform',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'uppercase', label: 'Uppercase' },
{ value: 'lowercase', label: 'Lowercase' },
{ value: 'capitalize', label: 'Capitalize' },
],
},
// Border
{ key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
{
key: 'borderStyle',
label: 'Border Style',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'double', label: 'Double' },
],
},
{ key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
{ key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
{ key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
// Grid Item (when inside grid container)
{ key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
{ key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
{ key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
{ key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
{ key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
{ key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
{
key: 'justifySelf',
label: 'Justify Self',
type: 'select',
category: 'grid-item',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
],
// Event schema: what events this component supports
eventSchema: [
{
type: 'click',
label: 'On Click',
description: 'Triggered when the button is clicked',
handlersKey: 'eventHandlers',
},
],
render: (props: ComponentRenderProps) => React.createElement(ButtonComponent, props),
};

View File

@ -0,0 +1,185 @@
import React from 'react';
import { ColumnIcon } from '../../icons/ComponentIcons';
import type { ComponentDefinition } from '../types';
export const columnDefinition: ComponentDefinition = {
type: 'column',
label: 'Column',
icon: ColumnIcon,
category: 'container',
canHaveChildren: true,
defaultStyles: {
width: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '8px',
},
defaultProps: {},
propertySchema: [],
styleSchema: [
// Layout
{ key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
{ key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
{ key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
{ key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
{ key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
{ key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
{
key: 'justifyContent',
label: 'Vertical Align',
type: 'select',
category: 'layout',
options: [
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'space-between', label: 'Space Between' },
{ value: 'space-around', label: 'Space Around' },
{ value: 'space-evenly', label: 'Space Evenly' },
],
},
{
key: 'alignItems',
label: 'Horizontal Align',
type: 'select',
category: 'layout',
options: [
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'baseline', label: 'Baseline' },
],
},
{
key: 'flexWrap',
label: 'Flex Wrap',
type: 'select',
category: 'layout',
options: [
{ value: 'nowrap', label: 'No Wrap' },
{ value: 'wrap', label: 'Wrap' },
{ value: 'wrap-reverse', label: 'Wrap Reverse' },
],
},
{
key: 'overflow',
label: 'Overflow',
type: 'select',
category: 'layout',
options: [
{ value: 'visible', label: 'Visible' },
{ value: 'hidden', label: 'Hidden' },
{ value: 'scroll', label: 'Scroll' },
{ value: 'auto', label: 'Auto' },
],
},
// Positioning
{
key: 'position',
label: 'Position',
type: 'select',
category: 'layout',
options: [
{ value: 'static', label: 'Static' },
{ value: 'relative', label: 'Relative' },
{ value: 'absolute', label: 'Absolute' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'sticky', label: 'Sticky' },
],
},
{ key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
{ key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
{ key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
{ key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
{ key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
// Spacing
{ key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' },
{ key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
{ key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
// Appearance
{ key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
{ key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
// Border
{ key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
{
key: 'borderStyle',
label: 'Border Style',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'double', label: 'Double' },
],
},
{ key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
{ key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
{ key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
// Grid Item (when inside grid container)
{ key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
{ key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
{ key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
{ key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
{ key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
{ key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
{
key: 'justifySelf',
label: 'Justify Self',
type: 'select',
category: 'grid-item',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
],
render: ({ component, isSelected, onSelect, previewMode, children }) => {
const isEmpty = !component.children || component.children.length === 0;
return React.createElement(
'div',
{
style: {
...component.styles,
outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
minHeight: isEmpty && !previewMode ? '60px' : 'auto',
position: 'relative' as const,
},
onClick: previewMode
? undefined
: (e: React.MouseEvent) => {
e.stopPropagation();
onSelect(component.id);
},
},
children,
isEmpty && !previewMode
? React.createElement(
'div',
{
style: {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#999',
fontSize: '14px',
pointerEvents: 'none' as const,
},
},
'Drop components here'
)
: null
);
},
};

View File

@ -0,0 +1,208 @@
import React from 'react';
import { GridIcon } from '../../icons/ComponentIcons';
import type { ComponentDefinition } from '../types';
import { ComponentRegistry } from '../ComponentRegistry';
export const gridDefinition: ComponentDefinition = {
type: 'grid',
label: 'Grid',
icon: GridIcon,
category: 'container',
canHaveChildren: true,
defaultStyles: {
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)', // 3 equal columns by default
gridTemplateRows: 'auto', // Auto rows
gap: '8px',
width: 'auto',
},
defaultProps: {},
propertySchema: [],
styleSchema: [
// Grid Structure
{ key: 'gridTemplateColumns', label: 'Grid Columns', type: 'text', category: 'grid' },
{ key: 'gridTemplateRows', label: 'Grid Rows', type: 'text', category: 'grid' },
{ key: 'gridAutoRows', label: 'Auto Rows', type: 'dimension', category: 'grid' },
{ key: 'gridAutoColumns', label: 'Auto Columns', type: 'dimension', category: 'grid' },
{
key: 'gridAutoFlow',
label: 'Auto Flow',
type: 'select',
category: 'grid',
options: [
{ value: 'row', label: 'Row' },
{ value: 'column', label: 'Column' },
{ value: 'dense', label: 'Dense' },
{ value: 'row dense', label: 'Row Dense' },
{ value: 'column dense', label: 'Column Dense' },
],
},
// Gap/Spacing
{ key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' },
{ key: 'rowGap', label: 'Row Gap', type: 'dimension', category: 'spacing' },
{ key: 'columnGap', label: 'Column Gap', type: 'dimension', category: 'spacing' },
{ key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
{ key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
// Grid Container Alignment
{
key: 'justifyItems',
label: 'Justify Items',
type: 'select',
category: 'alignment',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
{
key: 'alignItems',
label: 'Align Items',
type: 'select',
category: 'alignment',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
{
key: 'justifyContent',
label: 'Justify Content',
type: 'select',
category: 'alignment',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'space-between', label: 'Space Between' },
{ value: 'space-around', label: 'Space Around' },
{ value: 'space-evenly', label: 'Space Evenly' },
],
},
{
key: 'alignContent',
label: 'Align Content',
type: 'select',
category: 'alignment',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'space-between', label: 'Space Between' },
{ value: 'space-around', label: 'Space Around' },
{ value: 'space-evenly', label: 'Space Evenly' },
],
},
// Layout
{ key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
{ key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
{ key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
{ key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
{ key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
{ key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
{
key: 'overflow',
label: 'Overflow',
type: 'select',
category: 'layout',
options: [
{ value: 'visible', label: 'Visible' },
{ value: 'hidden', label: 'Hidden' },
{ value: 'scroll', label: 'Scroll' },
{ value: 'auto', label: 'Auto' },
],
},
// Positioning
{
key: 'position',
label: 'Position',
type: 'select',
category: 'layout',
options: [
{ value: 'static', label: 'Static' },
{ value: 'relative', label: 'Relative' },
{ value: 'absolute', label: 'Absolute' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'sticky', label: 'Sticky' },
],
},
{ key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
{ key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
{ key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
{ key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
{ key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
// Appearance
{ key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
{ key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
// Border
{ key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
{
key: 'borderStyle',
label: 'Border Style',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'double', label: 'Double' },
],
},
{ key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
{ key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
{ key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
],
render: ({ component, isSelected, onSelect, previewMode, children }) => {
const isEmpty = !component.children || component.children.length === 0;
return React.createElement(
'div',
{
style: {
...component.styles,
outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
minHeight: isEmpty && !previewMode ? '120px' : 'auto',
position: 'relative' as const,
},
onClick: previewMode
? undefined
: (e: React.MouseEvent) => {
e.stopPropagation();
onSelect(component.id);
},
},
children,
isEmpty && !previewMode
? React.createElement(
'div',
{
style: {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#999',
fontSize: '14px',
pointerEvents: 'none' as const,
},
},
'Drop components here'
)
: null
);
},
};
// Auto-register on import
ComponentRegistry.register(gridDefinition);

View File

@ -0,0 +1,42 @@
/**
* Component Definitions Index
*
* This file auto-registers all component definitions with the ComponentRegistry.
* Import this file in the app entry point to ensure all components are available.
*/
import { ComponentRegistry } from '../ComponentRegistry';
// Container components
import { rowDefinition } from './row';
import { columnDefinition } from './column';
import { boxDefinition } from './box';
import { gridDefinition } from './grid';
// Leaf components
import { buttonDefinition } from './button';
import { inputDefinition } from './input';
import { textDefinition } from './text';
import { listDefinition } from './list';
// Register all components
ComponentRegistry.register(rowDefinition);
ComponentRegistry.register(columnDefinition);
ComponentRegistry.register(boxDefinition);
ComponentRegistry.register(gridDefinition);
ComponentRegistry.register(buttonDefinition);
ComponentRegistry.register(inputDefinition);
ComponentRegistry.register(textDefinition);
ComponentRegistry.register(listDefinition);
// Export definitions for direct access if needed
export {
rowDefinition,
columnDefinition,
boxDefinition,
gridDefinition,
buttonDefinition,
inputDefinition,
textDefinition,
listDefinition,
};

View File

@ -0,0 +1,181 @@
import React from 'react';
import { InputIcon } from '../../icons/ComponentIcons';
import type { ComponentDefinition } from '../types';
import { parseScopedPath } from '../../state/scopedPath';
export const inputDefinition: ComponentDefinition = {
type: 'input',
label: 'Input',
icon: InputIcon,
category: 'leaf',
canHaveChildren: false,
defaultStyles: {
padding: '8px',
border: '1px solid #ccc',
borderRadius: '4px',
width: 'auto',
},
defaultProps: {
placeholder: 'Enter text...',
type: 'text',
value: '',
onChangeStatePath: '',
},
propertySchema: [
{ key: 'value', label: 'Value', type: 'text-binding', category: 'content' },
{ key: 'onChangeStatePath', label: 'On Change State Path', type: 'text-binding', category: 'content' },
{ key: 'placeholder', label: 'Placeholder', type: 'text', category: 'content' },
{
key: 'type',
label: 'Input Type',
type: 'select',
category: 'content',
options: [
{ value: 'text', label: 'Text' },
{ value: 'email', label: 'Email' },
{ value: 'password', label: 'Password' },
{ value: 'number', label: 'Number' },
{ value: 'tel', label: 'Tel' },
{ value: 'url', label: 'URL' },
],
},
],
styleSchema: [
// Layout
{ key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
{ key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
{ key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
{ key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
{
key: 'alignSelf',
label: 'Self Align',
type: 'select',
category: 'layout',
options: [
{ value: 'auto', label: 'Auto' },
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'baseline', label: 'Baseline' },
],
},
// Positioning
{
key: 'position',
label: 'Position',
type: 'select',
category: 'layout',
options: [
{ value: 'static', label: 'Static' },
{ value: 'relative', label: 'Relative' },
{ value: 'absolute', label: 'Absolute' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'sticky', label: 'Sticky' },
],
},
{ key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
{ key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
{ key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
{ key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
{ key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
// Spacing
{ key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
{ key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
// Appearance
{ key: 'color', label: 'Text Color', type: 'color', category: 'appearance' },
{ key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
{ key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
// Typography
{ key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' },
{
key: 'fontFamily',
label: 'Font Family',
type: 'select',
category: 'appearance',
options: [
{ value: 'inherit', label: 'Inherit' },
{ value: 'Arial, sans-serif', label: 'Arial' },
{ value: 'Helvetica, sans-serif', label: 'Helvetica' },
{ value: 'Georgia, serif', label: 'Georgia' },
{ value: '"Times New Roman", serif', label: 'Times New Roman' },
{ value: '"Courier New", monospace', label: 'Courier New' },
{ value: 'Verdana, sans-serif', label: 'Verdana' },
{ value: 'system-ui, sans-serif', label: 'System UI' },
],
},
// Border
{ key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
{
key: 'borderStyle',
label: 'Border Style',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'double', label: 'Double' },
],
},
{ key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
{ key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
{ key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
// Grid Item (when inside grid container)
{ key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
{ key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
{ key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
{ key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
{ key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
{ key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
{
key: 'justifySelf',
label: 'Justify Self',
type: 'select',
category: 'grid-item',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
],
render: ({ component, isSelected, onSelect, previewMode, runtime }) => {
// Resolve value binding
const valueBinding = component.props?.value || '';
const displayValue = runtime.resolveText(valueBinding);
// Handle onChange
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (previewMode && component.props?.onChangeStatePath) {
const parsedPath = parseScopedPath(component.props.onChangeStatePath);
runtime.setStateValue(parsedPath.fullPath, e.target.value);
}
};
return React.createElement('input', {
type: component.props?.type || 'text',
placeholder: component.props?.placeholder || 'Enter text...',
value: displayValue || '',
onChange: handleChange,
style: {
...component.styles,
outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
},
onClick: previewMode
? undefined
: (e: React.MouseEvent) => {
e.stopPropagation();
onSelect(component.id);
},
readOnly: !previewMode,
});
},
};

View File

@ -0,0 +1,381 @@
import React from 'react';
import { ListIcon } from '../../icons/ComponentIcons';
import type { ComponentDefinition, RuntimeValues } from '../types';
import { ComponentRegistry } from '../ComponentRegistry';
import { ComponentRenderer } from '../../utils/ComponentRenderer';
import type { ComponentNode, ComponentType } from '../../types/component.types';
import { parseScopedPath } from '../../state/scopedPath';
import { RuntimeContext } from '../../runtime';
import type { RuntimeContextValue } from '../../runtime';
/** Traverse a dot-path on an object, returning the resolved value as a string */
const resolveNestedValue = (obj: any, path: string): string => {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null || typeof current !== 'object') return '';
current = current[part];
if (current === undefined) return '';
}
return current == null ? '' : String(current);
};
/**
* Create a RuntimeContextValue for a single list iteration.
* Intercepts {{ dataSource.* }} and {{ index }} bindings before
* they reach the standard interpolateText (which only knows page/global).
*/
const createIterationContext = (
runtime: RuntimeValues,
item: any,
index: number,
): RuntimeContextValue => {
const iterationResolveText = (text: string): string => {
// Pre-process: resolve dataSource / index bindings first
const preprocessed = text.replace(
/\{\{\s*([^}]+?)\s*\}\}/g,
(match, rawPath) => {
const trimmed = rawPath.trim();
if (trimmed === 'index') {
return String(index);
}
if (trimmed === 'dataSource') {
return item == null ? '' : String(item);
}
if (trimmed.startsWith('dataSource.')) {
const subPath = trimmed.slice('dataSource.'.length);
return resolveNestedValue(item, subPath);
}
// Not a dataSource binding — pass through for standard resolution
return match;
},
);
// Resolve remaining {{ page.* }} / {{ global.* }} bindings
return runtime.resolveText(preprocessed);
};
return {
state: runtime.state,
resolveText: iterationResolveText,
executeHandlers: runtime.executeHandlers,
dispatchEvent: runtime.dispatchEvent,
pages: runtime.pages,
navigateToPage: runtime.navigateToPage,
setStateValue: runtime.setStateValue,
previewMode: true,
};
};
export const listDefinition: ComponentDefinition = {
type: 'list',
label: 'List',
icon: ListIcon,
category: 'container',
canHaveChildren: true, // Changed: now accepts children directly for inline editing
defaultStyles: {
width: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '8px',
},
defaultProps: {},
propertySchema: [
{
key: 'dataSource',
label: 'Data Source',
type: 'text-binding',
category: 'content',
description: 'Array data source (e.g., {{ page.cart.items }} or {{ global.products.items }})',
},
],
styleSchema: [
// Layout
{ key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
{ key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
{ key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
{ key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
{ key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
{ key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
{
key: 'flexDirection',
label: 'Direction',
type: 'select',
category: 'layout',
options: [
{ value: 'column', label: 'Column' },
{ value: 'row', label: 'Row' },
],
},
{
key: 'alignItems',
label: 'Align Items',
type: 'select',
category: 'layout',
options: [
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'stretch', label: 'Stretch' },
],
},
{
key: 'justifyContent',
label: 'Justify Content',
type: 'select',
category: 'layout',
options: [
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'space-between', label: 'Space Between' },
{ value: 'space-around', label: 'Space Around' },
],
},
{
key: 'overflow',
label: 'Overflow',
type: 'select',
category: 'layout',
options: [
{ value: 'visible', label: 'Visible' },
{ value: 'hidden', label: 'Hidden' },
{ value: 'scroll', label: 'Scroll' },
{ value: 'auto', label: 'Auto' },
],
},
// Spacing
{ key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' },
{ key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
{ key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
// Appearance
{ key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
{ key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
// Border
{ key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
{
key: 'borderStyle',
label: 'Border Style',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
],
},
{ key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
{ key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
// Grid Item (when inside grid container)
{ key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
{ key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
{ key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
{ key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
{ key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
{ key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
{
key: 'justifySelf',
label: 'Justify Self',
type: 'select',
category: 'grid-item',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
],
render: ({ component, isSelected, onSelect, previewMode, children, runtime }) => {
const isEmpty = !component.children || component.children.length === 0;
const dataSourceBinding = (component.props?.dataSource || '').trim();
// Helper function to extract array from data binding
const getDataArray = (): { array: any[] | null; error: string | null } => {
if (!dataSourceBinding || !runtime) {
return { array: null, error: null };
}
let parsedPath;
try {
parsedPath = parseScopedPath(dataSourceBinding);
} catch (error) {
return {
array: null,
error: error instanceof Error ? error.message : `Invalid binding format: "${dataSourceBinding}"`,
};
}
const parts = parsedPath.fullPath.split('.');
let current: any = runtime.state;
for (const part of parts) {
if (current == null || typeof current !== 'object') {
return { array: null, error: `Path "${parsedPath.fullPath}" not found in state` };
}
current = current[part];
if (current === undefined) {
return { array: null, error: `Path "${parsedPath.fullPath}" not found in state` };
}
}
if (!Array.isArray(current)) {
return {
array: null,
error: `Data source "${parsedPath.fullPath}" is not an array (found ${typeof current})`,
};
}
return { array: current, error: null };
};
// EDITOR MODE - always show 1 instance for design
if (!previewMode) {
return React.createElement(
'div',
{
style: {
...component.styles,
outline: isSelected ? '2px solid rgb(255, 89, 47)' : 'none',
minHeight: isEmpty ? '100px' : 'auto',
position: 'relative' as const,
},
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
onSelect(component.id);
},
},
isEmpty
? React.createElement(
'div',
{
style: {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#999',
fontSize: '14px',
textAlign: 'center' as const,
pointerEvents: 'none' as const,
},
},
'Drop components here to design list item'
)
: children
);
}
// PREVIEW MODE - render based on data source
if (isEmpty) {
return React.createElement(
'div',
{
style: {
...component.styles,
color: '#999',
padding: '16px',
textAlign: 'center' as const,
},
},
'No list items configured'
);
}
// If no data source, hide the list in preview
if (!dataSourceBinding) {
return null;
}
// Get data array
const { array: dataArray, error } = getDataArray();
// Show error if data source is invalid
if (error) {
return React.createElement(
'div',
{
style: {
...component.styles,
color: '#d32f2f',
padding: '16px',
backgroundColor: '#ffebee',
borderRadius: '4px',
border: '1px solid #ef5350',
},
},
`Error: ${error}`
);
}
// If array is empty, render nothing
if (!dataArray || dataArray.length === 0) {
return null;
}
// Create a recursive renderChildren function for nested components
const renderChildren = (children: ComponentNode[], parentType: ComponentType): React.ReactNode => {
return children.map((child) =>
React.createElement(
ComponentRenderer,
{
key: child.id,
component: child,
isSelected: null,
onSelect: () => { }, // Items in preview are not selectable
previewMode: true,
parentType: parentType,
renderChildren: (childChildren, childParentType) =>
renderChildren(childChildren, childParentType),
}
)
);
};
// Render N instances based on array length, each wrapped in an
// iteration-scoped RuntimeContext so children can resolve {{ dataSource.* }}
return React.createElement(
'div',
{
style: component.styles,
},
dataArray.map((item, index) => {
const iterationContext = createIterationContext(runtime, item, index);
return React.createElement(
RuntimeContext.Provider,
{
key: index,
value: iterationContext,
},
React.createElement(
'div',
null,
component.children!.map((child) =>
React.createElement(
ComponentRenderer,
{
key: `${child.id}-${index}`,
component: child,
isSelected: null,
onSelect: () => { },
previewMode: true,
parentType: component.type,
renderChildren: (childChildren, childParentType) =>
renderChildren(childChildren, childParentType),
}
)
)
)
);
})
);
},
};
// Auto-register on import
ComponentRegistry.register(listDefinition);

View File

@ -0,0 +1,185 @@
import React from 'react';
import { RowIcon } from '../../icons/ComponentIcons';
import type { ComponentDefinition } from '../types';
export const rowDefinition: ComponentDefinition = {
type: 'row',
label: 'Row',
icon: RowIcon,
category: 'container',
canHaveChildren: true,
defaultStyles: {
width: 'auto',
display: 'flex',
flexDirection: 'row',
gap: '8px',
},
defaultProps: {},
propertySchema: [],
styleSchema: [
// Layout
{ key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
{ key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
{ key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
{ key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
{ key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' },
{ key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' },
{
key: 'justifyContent',
label: 'Horizontal Align',
type: 'select',
category: 'layout',
options: [
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'space-between', label: 'Space Between' },
{ value: 'space-around', label: 'Space Around' },
{ value: 'space-evenly', label: 'Space Evenly' },
],
},
{
key: 'alignItems',
label: 'Vertical Align',
type: 'select',
category: 'layout',
options: [
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'baseline', label: 'Baseline' },
],
},
{
key: 'flexWrap',
label: 'Flex Wrap',
type: 'select',
category: 'layout',
options: [
{ value: 'nowrap', label: 'No Wrap' },
{ value: 'wrap', label: 'Wrap' },
{ value: 'wrap-reverse', label: 'Wrap Reverse' },
],
},
{
key: 'overflow',
label: 'Overflow',
type: 'select',
category: 'layout',
options: [
{ value: 'visible', label: 'Visible' },
{ value: 'hidden', label: 'Hidden' },
{ value: 'scroll', label: 'Scroll' },
{ value: 'auto', label: 'Auto' },
],
},
// Positioning
{
key: 'position',
label: 'Position',
type: 'select',
category: 'layout',
options: [
{ value: 'static', label: 'Static' },
{ value: 'relative', label: 'Relative' },
{ value: 'absolute', label: 'Absolute' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'sticky', label: 'Sticky' },
],
},
{ key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
{ key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
{ key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
{ key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
{ key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
// Spacing
{ key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' },
{ key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
{ key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
// Appearance
{ key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
{ key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
// Border
{ key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
{
key: 'borderStyle',
label: 'Border Style',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'double', label: 'Double' },
],
},
{ key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
{ key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
{ key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
// Grid Item (when inside grid container)
{ key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
{ key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
{ key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
{ key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
{ key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
{ key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
{
key: 'justifySelf',
label: 'Justify Self',
type: 'select',
category: 'grid-item',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
],
render: ({ component, isSelected, onSelect, previewMode, children }) => {
const isEmpty = !component.children || component.children.length === 0;
return React.createElement(
'div',
{
style: {
...component.styles,
outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
minHeight: isEmpty && !previewMode ? '60px' : 'auto',
position: 'relative' as const,
},
onClick: previewMode
? undefined
: (e: React.MouseEvent) => {
e.stopPropagation();
onSelect(component.id);
},
},
children,
isEmpty && !previewMode
? React.createElement(
'div',
{
style: {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#999',
fontSize: '14px',
pointerEvents: 'none' as const,
},
},
'Drop components here'
)
: null
);
},
};

View File

@ -0,0 +1,231 @@
import React from 'react';
import { TextIcon } from '../../icons/ComponentIcons';
import type { ComponentDefinition, ComponentRenderProps } from '../types';
/**
* Text component - now decoupled from stores
* Uses runtime.resolveText() instead of direct store access
*/
export const textDefinition: ComponentDefinition = {
type: 'text',
label: 'Text',
icon: TextIcon,
category: 'leaf',
canHaveChildren: false,
defaultStyles: {
width: 'auto',
alignSelf: 'auto',
},
defaultProps: {
text: 'Text',
elementType: 'p',
},
propertySchema: [
{ key: 'text', label: 'Text', type: 'text-binding', category: 'content' },
{
key: 'elementType',
label: 'Element Type',
type: 'select',
category: 'content',
options: [
{ value: 'p', label: 'Paragraph (p)' },
{ value: 'span', label: 'Span' },
{ value: 'div', label: 'Div' },
{ value: 'h1', label: 'Heading 1 (h1)' },
{ value: 'h2', label: 'Heading 2 (h2)' },
{ value: 'h3', label: 'Heading 3 (h3)' },
{ value: 'h4', label: 'Heading 4 (h4)' },
{ value: 'h5', label: 'Heading 5 (h5)' },
{ value: 'h6', label: 'Heading 6 (h6)' },
],
},
],
styleSchema: [
// Layout
{ key: 'width', label: 'Width', type: 'dimension', category: 'layout' },
{ key: 'height', label: 'Height', type: 'dimension', category: 'layout' },
{ key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' },
{ key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' },
{
key: 'alignSelf',
label: 'Self Align',
type: 'select',
category: 'layout',
options: [
{ value: 'auto', label: 'Auto' },
{ value: 'flex-start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'flex-end', label: 'End' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'baseline', label: 'Baseline' },
],
},
// Positioning
{
key: 'position',
label: 'Position',
type: 'select',
category: 'layout',
options: [
{ value: 'static', label: 'Static' },
{ value: 'relative', label: 'Relative' },
{ value: 'absolute', label: 'Absolute' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'sticky', label: 'Sticky' },
],
},
{ key: 'top', label: 'Top', type: 'dimension', category: 'layout' },
{ key: 'right', label: 'Right', type: 'dimension', category: 'layout' },
{ key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' },
{ key: 'left', label: 'Left', type: 'dimension', category: 'layout' },
{ key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' },
// Spacing
{ key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' },
{ key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' },
// Appearance
{ key: 'color', label: 'Text Color', type: 'color', category: 'appearance' },
{ key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' },
{ key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' },
// Typography
{ key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' },
{
key: 'fontFamily',
label: 'Font Family',
type: 'select',
category: 'appearance',
options: [
{ value: 'inherit', label: 'Inherit' },
{ value: 'Arial, sans-serif', label: 'Arial' },
{ value: 'Helvetica, sans-serif', label: 'Helvetica' },
{ value: 'Georgia, serif', label: 'Georgia' },
{ value: '"Times New Roman", serif', label: 'Times New Roman' },
{ value: '"Courier New", monospace', label: 'Courier New' },
{ value: 'Verdana, sans-serif', label: 'Verdana' },
{ value: 'system-ui, sans-serif', label: 'System UI' },
],
},
{
key: 'fontWeight',
label: 'Font Weight',
type: 'select',
category: 'appearance',
options: [
{ value: 'normal', label: 'Normal' },
{ value: 'bold', label: 'Bold' },
{ value: '100', label: '100 (Thin)' },
{ value: '200', label: '200' },
{ value: '300', label: '300 (Light)' },
{ value: '400', label: '400 (Normal)' },
{ value: '500', label: '500 (Medium)' },
{ value: '600', label: '600 (Semibold)' },
{ value: '700', label: '700 (Bold)' },
{ value: '800', label: '800' },
{ value: '900', label: '900 (Black)' },
],
},
{ key: 'lineHeight', label: 'Line Height', type: 'dimension', category: 'appearance' },
{ key: 'letterSpacing', label: 'Letter Spacing', type: 'dimension', category: 'appearance' },
{
key: 'textAlign',
label: 'Text Align',
type: 'select',
category: 'appearance',
options: [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
{ value: 'justify', label: 'Justify' },
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
],
},
{
key: 'textDecoration',
label: 'Text Decoration',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'underline', label: 'Underline' },
{ value: 'line-through', label: 'Line Through' },
{ value: 'overline', label: 'Overline' },
],
},
{
key: 'textTransform',
label: 'Text Transform',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'uppercase', label: 'Uppercase' },
{ value: 'lowercase', label: 'Lowercase' },
{ value: 'capitalize', label: 'Capitalize' },
],
},
// Border
{ key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' },
{
key: 'borderStyle',
label: 'Border Style',
type: 'select',
category: 'appearance',
options: [
{ value: 'none', label: 'None' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'double', label: 'Double' },
],
},
{ key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' },
{ key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' },
{ key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' },
// Grid Item (when inside grid container)
{ key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' },
{ key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' },
{ key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' },
{ key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' },
{ key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' },
{ key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' },
{
key: 'justifySelf',
label: 'Justify Self',
type: 'select',
category: 'grid-item',
options: [
{ value: 'start', label: 'Start' },
{ value: 'end', label: 'End' },
{ value: 'center', label: 'Center' },
{ value: 'stretch', label: 'Stretch' },
],
},
],
render: ({ component, isSelected, onSelect, previewMode, runtime }: ComponentRenderProps) => {
const elementType = component.props?.elementType || 'p';
const rawTextContent = component.props?.text || 'Text';
// Use runtime.resolveText instead of directly accessing stores
const textContent = runtime.resolveText(rawTextContent);
const commonProps = {
style: {
...component.styles,
outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none',
},
onClick: previewMode
? undefined
: (e: React.MouseEvent) => {
e.stopPropagation();
onSelect(component.id);
},
};
return React.createElement(elementType, commonProps, textContent);
},
};

View File

@ -0,0 +1,36 @@
/**
* Component Registry
*
* Centralized registry for all component definitions.
*
* Usage:
* 1. Import the registry to access component definitions:
* import { ComponentRegistry } from './registry';
*
* 2. Import with definitions auto-registration (do this once in app entry):
* import './registry/definitions';
*
* 3. Get a component definition:
* const buttonDef = ComponentRegistry.get('button');
*
* 4. Get all components for component library:
* const allComponents = ComponentRegistry.getAll();
*
* 5. Get defaults when creating components:
* const styles = ComponentRegistry.getDefaultStyles('button');
* const props = ComponentRegistry.getDefaultProps('button');
*/
export { ComponentRegistry } from './ComponentRegistry';
export type {
ComponentDefinition,
ComponentRenderProps,
EditorRenderProps,
PropertySchema,
StyleSchema,
PropertyEditorProps,
StyleEditorProps,
RuntimeValues,
ComponentEventType,
EventSchema,
} from './types';

View File

@ -0,0 +1,173 @@
import type { CSSProperties, ReactNode } from 'react';
import type { ComponentNode, Page, EventHandler } from '../types/component.types';
/**
* Supported event types for components
*/
export type ComponentEventType = 'click' | 'change' | 'submit' | 'focus' | 'blur';
/**
* Event schema defining what events a component supports
*/
export interface EventSchema {
/** Event type (e.g., 'click', 'change') */
type: ComponentEventType;
/** Display label for the event */
label: string;
/** Description of when this event fires */
description?: string;
/** Property key where handlers are stored (defaults to 'eventHandlers') */
handlersKey?: string;
}
/**
* Runtime values available to components during rendering
*/
export interface RuntimeValues {
/** Current scoped runtime state */
state: {
page: Record<string, any>;
global: Record<string, any>;
};
/** Resolve text with scoped state bindings ({{ page.* }} / {{ global.* }} syntax) */
resolveText: (text: string) => string;
/** Execute event handlers */
executeHandlers: (handlers: EventHandler[]) => Promise<void>;
/** Dispatch an event for a component */
dispatchEvent: (eventType: ComponentEventType, component: ComponentNode, handlersKey?: string) => Promise<void>;
/** All pages (for navigation) */
pages: Page[];
/** Navigate to a page by ID */
navigateToPage: (pageId: string) => void;
/** Update state value at a given path */
setStateValue: (path: string, value: any) => void;
}
/**
* Schema definition for a component property (used in Properties Panel)
*/
export interface PropertySchema {
/** Property key in component.props */
key: string;
/** Display label in properties panel */
label: string;
/** Editor type to use */
type: 'text' | 'text-binding' | 'number' | 'color' | 'select' | 'boolean' | 'custom';
/** Default value */
default?: any;
/** Options for select type */
options?: { value: string; label: string }[];
/** Category for grouping properties */
category?: 'content' | 'layout' | 'style' | 'events';
/** For custom type: the custom editor key */
customEditor?: string;
/** Description/hint text */
description?: string;
}
/**
* Schema definition for component styles
*/
export interface StyleSchema {
/** CSS property key */
key: string;
/** Display label */
label: string;
/** Editor type */
type: 'text' | 'number' | 'color' | 'select' | 'dimension' | 'boxShadow' | 'range';
/** Default value */
default?: any;
/** Options for select type */
options?: { value: string; label: string }[];
/** Category for grouping */
category?: 'layout' | 'spacing' | 'appearance' | 'grid' | 'grid-item' | 'alignment';
}
/**
* Props passed to component render functions
*/
export interface ComponentRenderProps {
component: ComponentNode;
isSelected: boolean;
onSelect: (id: string) => void;
previewMode: boolean;
children?: ReactNode;
/** Runtime values from context (decouples components from stores) */
runtime: RuntimeValues;
}
/**
* Props specific to editor render (extends ComponentRenderProps)
*/
export interface EditorRenderProps extends ComponentRenderProps {
// Editor-specific props can be added here
}
/**
* Props passed to property editors
*/
export interface PropertyEditorProps {
value: any;
onChange: (value: any, isIntermediate?: boolean) => void;
schema: PropertySchema;
component: ComponentNode;
pages?: Page[];
}
/**
* Props passed to style editors
*/
export interface StyleEditorProps {
value: any;
onChange: (value: any, isIntermediate?: boolean) => void;
schema: StyleSchema;
component: ComponentNode;
}
/**
* Complete component definition for the registry
*/
export interface ComponentDefinition {
/** Unique type identifier (e.g., 'button', 'row') */
type: string;
/** Display label in component library */
label: string;
/** Optional icon component for visual identification in outline */
icon?: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>;
/** Category: container (can have children) or leaf (cannot) */
category: 'container' | 'leaf';
/** Whether this component can accept child components */
canHaveChildren: boolean;
/** Default CSS styles applied when component is created */
defaultStyles: CSSProperties;
/** Default props applied when component is created */
defaultProps: Record<string, any>;
/** Schema for component-specific properties */
propertySchema: PropertySchema[];
/** Schema for style properties */
styleSchema: StyleSchema[];
/** Schema for events the component supports */
eventSchema?: EventSchema[];
/**
* Render function for the component
* Receives standardized props, returns React node
*/
render: (props: ComponentRenderProps) => ReactNode;
/**
* Optional: Editor-specific render override
* If not provided, uses render() with previewMode=false
*/
editorRender?: (props: EditorRenderProps) => ReactNode;
}

View File

@ -0,0 +1,58 @@
import { createContext, useContext } from 'react';
import type { EventHandler, Page, ComponentNode } from '../types/component.types';
import type { ComponentEventType } from '../registry/types';
/**
* Runtime context value interface
* Components receive these values instead of accessing stores directly.
*/
export interface RuntimeContextValue {
/** Current scoped runtime state: only `page` and `global` are available. */
state: {
page: Record<string, any>;
global: Record<string, any>;
};
/** Resolve scoped bindings in text ({{ page.* }} / {{ global.* }} syntax) */
resolveText: (text: string) => string;
/** Execute event handlers (for button clicks, etc.) */
executeHandlers: (handlers: EventHandler[]) => Promise<void>;
/** Dispatch an event for a component */
dispatchEvent: (eventType: ComponentEventType, component: ComponentNode, handlersKey?: string) => Promise<void>;
/** All pages (for navigation) */
pages: Page[];
/** Navigate to a page by ID */
navigateToPage: (pageId: string) => void;
/** Update state value at a given path */
setStateValue: (path: string, value: any) => void;
/** Whether we're in preview/runtime mode */
previewMode: boolean;
}
export const RuntimeContext = createContext<RuntimeContextValue | null>(null);
/**
* Hook to access runtime context
* Throws if used outside a provider.
*/
export const useRuntime = (): RuntimeContextValue => {
const context = useContext(RuntimeContext);
if (!context) {
throw new Error('useRuntime must be used within a RuntimeContext provider');
}
return context;
};
/**
* Hook to access runtime context (returns null if outside provider)
* Useful for components that may render outside runtime context.
*/
export const useRuntimeOptional = (): RuntimeContextValue | null => {
return useContext(RuntimeContext);
};

View File

@ -0,0 +1,3 @@
export { RuntimeContext, useRuntime, useRuntimeOptional } from './RuntimeContext';
export type { RuntimeContextValue } from './RuntimeContext';

View File

@ -0,0 +1,88 @@
export type ScopedScope = 'page' | 'global';
export type ScopedStatePath = `page.${string}` | `global.${string}`;
export interface ParsedScopedPath {
scope: ScopedScope;
relativePath: string;
fullPath: ScopedStatePath;
}
const BINDING_PATTERN = /^\{\{\s*([^{}]+?)\s*\}\}$/;
const SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const INDEX_SEGMENT_PATTERN = /^\d+$/;
const normalizeInput = (rawPath: unknown): string => {
if (typeof rawPath !== 'string') {
throw new Error('State path must be a string');
}
const input = rawPath.trim();
if (!input) {
throw new Error('State path is required');
}
if (input.includes('{{') || input.includes('}}')) {
const bindingMatch = input.match(BINDING_PATTERN);
if (!bindingMatch) {
throw new Error('State path binding must use valid {{ ... }} format');
}
const boundValue = bindingMatch[1].trim();
if (!boundValue) {
throw new Error('State path binding cannot be empty');
}
return boundValue;
}
return input;
};
const validateRelativePath = (relativePath: string): void => {
if (!relativePath) {
throw new Error('State path must include a property after scope');
}
if (relativePath.startsWith('.') || relativePath.endsWith('.') || relativePath.includes('..')) {
throw new Error('State path must not start/end with dots or contain consecutive dots');
}
const segments = relativePath.split('.');
for (const segment of segments) {
if (!SEGMENT_PATTERN.test(segment) && !INDEX_SEGMENT_PATTERN.test(segment)) {
throw new Error(
'State path segments must be alphanumeric/underscore and start with a letter or underscore (indexes can be numeric)'
);
}
}
};
export const parseScopedPath = (rawPath: unknown): ParsedScopedPath => {
const normalized = normalizeInput(rawPath);
let scope: ScopedScope;
if (normalized.startsWith('page.')) {
scope = 'page';
} else if (normalized.startsWith('global.')) {
scope = 'global';
} else {
throw new Error('State path must start with "page." or "global."');
}
const relativePath = normalized.slice(`${scope}.`.length);
validateRelativePath(relativePath);
const fullPath = `${scope}.${relativePath}` as ScopedStatePath;
return {
scope,
relativePath,
fullPath,
};
};
export const isScopedPath = (rawPath: unknown): rawPath is ScopedStatePath => {
try {
parseScopedPath(rawPath);
return true;
} catch {
return false;
}
};

View File

@ -0,0 +1,91 @@
const isIndexSegment = (segment: string | undefined): boolean => !!segment && /^\d+$/.test(segment);
const ensureContainerForPath = (nextSegment: string | undefined) =>
nextSegment && isIndexSegment(nextSegment) ? [] : {};
export const updateStateAtPath = (currentState: any, pathParts: string[], newValue: any): any => {
if (pathParts.length === 0) {
return newValue;
}
const [first, ...rest] = pathParts;
const nextContainer = ensureContainerForPath(rest[0]);
if (Array.isArray(currentState)) {
const updatedArray = [...currentState];
if (isIndexSegment(first)) {
const index = Number(first);
const currentValue = updatedArray[index];
if (rest.length === 0) {
updatedArray[index] = newValue;
} else {
const child = currentValue ?? nextContainer;
updatedArray[index] = updateStateAtPath(child, rest, newValue);
}
} else {
const currentValue = (updatedArray as any)[first];
if (rest.length === 0) {
(updatedArray as any)[first] = newValue;
} else {
const child = currentValue ?? nextContainer;
(updatedArray as any)[first] = updateStateAtPath(child, rest, newValue);
}
}
return updatedArray;
}
const baseObject = currentState && typeof currentState === 'object' ? currentState : {};
const updatedObject: Record<string, any> = Array.isArray(baseObject)
? [...baseObject]
: { ...baseObject };
if (rest.length === 0) {
updatedObject[first] = newValue;
return updatedObject;
}
const currentValue = updatedObject[first];
const child = currentValue ?? nextContainer;
updatedObject[first] = updateStateAtPath(child, rest, newValue);
return updatedObject;
};
export const deleteStateAtPath = (currentState: any, pathParts: string[]): any => {
if (!currentState || pathParts.length === 0) {
return currentState;
}
const [first, ...rest] = pathParts;
if (Array.isArray(currentState)) {
const updatedArray = [...currentState];
if (isIndexSegment(first)) {
const index = Number(first);
if (rest.length === 0) {
updatedArray.splice(index, 1);
return updatedArray;
}
updatedArray[index] = deleteStateAtPath(updatedArray[index], rest);
return updatedArray;
}
(updatedArray as any)[first] = rest.length === 0
? undefined
: deleteStateAtPath((updatedArray as any)[first], rest);
return updatedArray;
}
if (typeof currentState !== 'object') {
return currentState;
}
const updatedObject: Record<string, any> = { ...currentState };
if (rest.length === 0) {
delete updatedObject[first];
return updatedObject;
}
updatedObject[first] = deleteStateAtPath(updatedObject[first], rest);
return updatedObject;
};

View File

@ -0,0 +1,266 @@
import type { ComponentNode, EventHandler, PagesData } from '../types/component.types';
import { parseScopedPath } from './scopedPath';
import { normalizeWorkflowResultKey } from '../utils/workflowResultKey';
export const MAX_VIOLATIONS_TO_SHOW = 20;
export interface ScopedBindingViolation {
pageId: string;
pageName: string;
componentId: string;
fieldPath: string;
value: string;
reason: string;
}
const BINDING_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g;
/** Returns true if the expression is a valid list-iteration binding (dataSource, dataSource.*, index) */
const isIterationBinding = (expression: string): boolean =>
expression === 'index' || expression === 'dataSource' || expression.startsWith('dataSource.');
const addViolation = (
violations: ScopedBindingViolation[],
pageId: string,
pageName: string,
componentId: string,
fieldPath: string,
value: string,
reason: string
) => {
violations.push({
pageId,
pageName,
componentId,
fieldPath,
value,
reason,
});
};
const validateBindingTokens = (
text: string,
violations: ScopedBindingViolation[],
pageId: string,
pageName: string,
componentId: string,
fieldPath: string,
insideList: boolean = false
) => {
const matches = text.matchAll(BINDING_PATTERN);
for (const match of matches) {
const expression = (match[1] || '').trim();
if (!expression) {
addViolation(violations, pageId, pageName, componentId, fieldPath, match[0], 'Empty binding expression');
continue;
}
// Allow dataSource.* and index bindings inside list components
if (insideList && isIterationBinding(expression)) {
continue;
}
try {
parseScopedPath(expression);
} catch (error) {
addViolation(
violations,
pageId,
pageName,
componentId,
fieldPath,
match[0],
error instanceof Error ? error.message : 'Invalid scoped binding'
);
}
}
};
const scanValueForBindings = (
value: unknown,
violations: ScopedBindingViolation[],
pageId: string,
pageName: string,
componentId: string,
fieldPath: string,
insideList: boolean = false
) => {
if (typeof value === 'string') {
validateBindingTokens(value, violations, pageId, pageName, componentId, fieldPath, insideList);
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
scanValueForBindings(item, violations, pageId, pageName, componentId, `${fieldPath}[${index}]`, insideList);
});
return;
}
if (value && typeof value === 'object') {
Object.entries(value as Record<string, unknown>).forEach(([key, item]) => {
scanValueForBindings(item, violations, pageId, pageName, componentId, `${fieldPath}.${key}`, insideList);
});
}
};
const validateHandler = (
handler: EventHandler,
violations: ScopedBindingViolation[],
pageId: string,
pageName: string,
componentId: string,
handlerPath: string,
insideList: boolean = false
) => {
if (handler.action === 'setState') {
if (!handler.statePath || typeof handler.statePath !== 'string') {
addViolation(
violations,
pageId,
pageName,
componentId,
`${handlerPath}.statePath`,
String(handler.statePath ?? ''),
'Missing statePath for setState handler'
);
} else {
try {
parseScopedPath(handler.statePath);
} catch (error) {
addViolation(
violations,
pageId,
pageName,
componentId,
`${handlerPath}.statePath`,
handler.statePath,
error instanceof Error ? error.message : 'Invalid scoped state path'
);
}
}
}
if (handler.action === 'workflow' && typeof handler.workflowResultKey === 'string' && handler.workflowResultKey.trim()) {
try {
const normalized = normalizeWorkflowResultKey(handler.workflowResultKey);
if (!normalized.fullPath.startsWith('global.workflows.')) {
addViolation(
violations,
pageId,
pageName,
componentId,
`${handlerPath}.workflowResultKey`,
handler.workflowResultKey,
'Workflow result must resolve under global.workflows.*'
);
}
} catch (error) {
addViolation(
violations,
pageId,
pageName,
componentId,
`${handlerPath}.workflowResultKey`,
handler.workflowResultKey,
error instanceof Error ? error.message : 'Invalid workflow result key'
);
}
}
if (typeof handler.value === 'string') {
validateBindingTokens(handler.value, violations, pageId, pageName, componentId, `${handlerPath}.value`, insideList);
}
if (typeof handler.condition === 'string') {
validateBindingTokens(handler.condition, violations, pageId, pageName, componentId, `${handlerPath}.condition`, insideList);
}
if (typeof handler.requestBody === 'string') {
validateBindingTokens(handler.requestBody, violations, pageId, pageName, componentId, `${handlerPath}.requestBody`, insideList);
}
};
const scanComponent = (
component: ComponentNode,
violations: ScopedBindingViolation[],
pageId: string,
pageName: string,
pathPrefix: string,
insideList: boolean = false
) => {
const componentPath = `${pathPrefix}.component(${component.id})`;
const props = component.props ?? {};
// Children of a list component are inside a list iteration scope
const childInsideList = insideList || component.type === 'list';
Object.entries(props).forEach(([key, value]) => {
const propPath = `${componentPath}.props.${key}`;
if (key === 'onChangeStatePath' && typeof value === 'string' && value.trim()) {
try {
parseScopedPath(value);
} catch (error) {
addViolation(
violations,
pageId,
pageName,
component.id,
propPath,
value,
error instanceof Error ? error.message : 'Invalid scoped state path'
);
}
}
if (key.endsWith('Handlers') && Array.isArray(value)) {
value.forEach((handler, index) => {
if (!handler || typeof handler !== 'object') return;
validateHandler(
handler as EventHandler,
violations,
pageId,
pageName,
component.id,
`${propPath}[${index}]`,
insideList
);
});
}
scanValueForBindings(value, violations, pageId, pageName, component.id, propPath, insideList);
});
(component.children ?? []).forEach((child, index) => {
scanComponent(child, violations, pageId, pageName, `${componentPath}.children[${index}]`, childInsideList);
});
};
export const validatePagesDataScopedBindings = (pagesData: PagesData): ScopedBindingViolation[] => {
const violations: ScopedBindingViolation[] = [];
if (!pagesData || !Array.isArray(pagesData.pages)) {
return violations;
}
pagesData.pages.forEach((page, pageIndex) => {
if (!page?.root) return;
scanComponent(page.root, violations, page.id, page.name, `pages[${pageIndex}]`);
});
return violations;
};
export const formatScopedBindingViolations = (
violations: ScopedBindingViolation[],
limit: number = MAX_VIOLATIONS_TO_SHOW
): string => {
if (!violations.length) return '';
const visible = violations.slice(0, limit);
const lines = visible.map((violation, index) => {
return `${index + 1}. [${violation.pageName}] component=${violation.componentId} field=${violation.fieldPath} value="${violation.value}" reason=${violation.reason}`;
});
const remaining = violations.length - visible.length;
if (remaining > 0) {
lines.push(`...and ${remaining} more violation(s).`);
}
return lines.join('\n');
};

View File

@ -0,0 +1,54 @@
import type { CSSProperties } from 'react';
import type { ScopedStatePath } from '../state/scopedPath';
export type ComponentType = 'row' | 'column' | 'box' | 'button' | 'input' | 'text' | 'list' | 'grid';
export type Viewport = 'mobile' | 'tablet' | 'desktop';
export interface ComponentNode {
id: string;
type: ComponentType;
styles: CSSProperties;
props?: Record<string, any>;
children?: ComponentNode[];
layerName?: string;
}
export interface Page {
id: string;
name: string;
route: string;
root: ComponentNode | null;
isDefault: boolean;
}
export interface PagesData {
pages: Page[];
currentPageId: string | null;
stateData?: StateData;
pageStateData?: Record<string, StateData>;
}
export interface StateData {
state: Record<string, any>;
version: number;
}
export interface EventHandler {
id: string; // UUID for each handler
action: 'setState' | 'navigateToPage' | 'workflow';
// Conditional execution:
condition?: string; // JavaScript expression that must evaluate to true for handler to execute (e.g., "page.user.mobileNumber && page.user.mobileNumber.length >= 10")
conditionErrorMessage?: string; // Error message to show if condition fails (optional)
// For setState:
statePath?: ScopedStatePath; // e.g., "page.user.customerName" or "global.user.customerName"
value?: any; // JavaScript expression as string (e.g., "page.user.age + 1", "global.user.name.toUpperCase()")
// For navigateToPage:
pageId?: string; // Target page ID
// For workflow:
workflowId?: string; // Workflow ID from API
workflowName?: string; // Selected workflow name
workflowUrl?: string; // Workflow execution URL
requestBody?: string; // JSON string for workflow request body (supports {{ page.* }} / {{ global.* }} bindings)
workflowResultKey?: string; // Relative key under global.workflows.* to store workflow results
}

View File

@ -0,0 +1,122 @@
import React, { type CSSProperties } from "react";
import type { ComponentNode, ComponentType } from '../types/component.types';
import { ComponentRegistry } from '../registry';
import type { RuntimeValues } from '../registry/types';
import { normalizeComponentStyles } from './styleNormalization';
import { useRuntimeOptional } from '../runtime';
interface ComponentRendererProps {
component: ComponentNode;
isSelected: string | null;
lastModifiedId?: string | null;
onSelect: (id: string) => void;
renderChildren?: (children: ComponentNode[], parentType: ComponentType, parentStyles?: CSSProperties) => React.ReactNode;
previewMode?: boolean;
parentType?: ComponentType | null;
parentStyles?: CSSProperties | null;
}
// Default no-op runtime values for when context is not available
const defaultRuntime: RuntimeValues = {
state: { page: {}, global: {} },
resolveText: (text: string) => text,
executeHandlers: async () => { },
dispatchEvent: async () => { },
pages: [],
navigateToPage: () => { },
setStateValue: () => { },
};
export const ComponentRenderer: React.FC<ComponentRendererProps> = ({
component,
isSelected,
lastModifiedId,
onSelect,
renderChildren,
previewMode = false,
parentType = null,
parentStyles = null,
}) => {
const isSelectedComponent = isSelected === component.id;
const isModified = lastModifiedId === component.id;
const domRef = React.useRef<HTMLDivElement>(null);
// Get runtime context (may be null if not wrapped in provider)
const runtimeContext = useRuntimeOptional();
// Scroll into view and pulse when modified and selected (e.g. undo/redo)
React.useEffect(() => {
if (isSelectedComponent && isModified && !previewMode) {
// Use requestAnimationFrame to ensure the DOM is ready (especially for restored components)
requestAnimationFrame(() => {
if (domRef.current) {
domRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
domRef.current.classList.remove('pulse-highlight');
// Trigger reflow to restart animation
void domRef.current.offsetWidth;
domRef.current.classList.add('pulse-highlight');
}
});
}
}, [isSelectedComponent, isModified, previewMode]);
// Normalize styles to prevent flexbox stretching, passing parent styles so the
// normalizer can skip injection when the parent has explicit alignment intent.
const normalizedStyles = normalizeComponentStyles(component.type, component.styles, parentType, parentStyles);
const normalizedComponent = { ...component, styles: normalizedStyles };
// Helper to render children with parent context.
// We pass normalizedStyles so children can see this component's effective alignment.
const renderChildrenWithContext = (children: ComponentNode[]) => {
if (!renderChildren) return null;
return renderChildren(children, component.type, normalizedStyles);
};
// Build runtime values from context or defaults
const runtime: RuntimeValues = runtimeContext
? {
state: runtimeContext.state,
resolveText: runtimeContext.resolveText,
executeHandlers: runtimeContext.executeHandlers,
dispatchEvent: runtimeContext.dispatchEvent,
pages: runtimeContext.pages,
navigateToPage: runtimeContext.navigateToPage,
setStateValue: runtimeContext.setStateValue,
}
: defaultRuntime;
const renderComponent = () => {
// Get component definition from registry
const definition = ComponentRegistry.get(component.type);
if (!definition) {
console.warn(`Unknown component type: ${component.type}`);
return null;
}
// Prepare children for containers
const childrenContent = definition.canHaveChildren && renderChildren && component.children
? renderChildrenWithContext(component.children)
: undefined;
// Call the render function from the definition with runtime values
return definition.render({
component: normalizedComponent,
isSelected: isSelectedComponent,
onSelect,
previewMode,
children: childrenContent,
runtime,
});
};
if (previewMode) {
return <>{renderComponent()}</>;
}
return (
<div ref={domRef} style={{ display: 'contents' }}>
{renderComponent()}
</div>
);
};

View File

@ -0,0 +1,155 @@
/**
* Safe JavaScript expression evaluator with state context
*/
export interface EvaluationContext {
state: any;
}
/**
* Create a deep proxy that returns safe defaults for undefined properties
*
* This prevents "undefined" from appearing in expressions when properties don't exist.
* Instead of `undefined + "1"` becoming `"undefined1"`, it becomes `"" + "1"` = `"1"`.
*
* @param state - The state object to wrap
* @returns A proxied state object that returns "" for undefined properties
*
* @example
* const safeState = createSafeStateProxy({ page: { user: { name: "Jon" } }, global: {} });
* safeState.page.user.mobileNumber // Returns "" instead of undefined
* safeState.page.user.name // Returns "Jon" (existing values unchanged)
*/
const createSafeStateProxy = (state: any): any => {
// Handle null/undefined at the root
if (state === null || state === undefined) {
return "";
}
// Don't proxy primitives or special objects
if (typeof state !== 'object' || state instanceof Date || state instanceof RegExp) {
return state;
}
// Handle arrays specially
if (Array.isArray(state)) {
return new Proxy(state, {
get(target, prop) {
// Array methods and properties
if (prop === 'length' || typeof target[prop as any] === 'function') {
return target[prop as any];
}
const value = target[prop as any];
// If index exists, return proxied value
if (value !== undefined) {
if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
return createSafeStateProxy(value);
}
return value;
}
// Out of bounds index - return empty string
return "";
}
});
}
// Proxy objects
return new Proxy(state, {
get(target, prop) {
const value = target[prop];
// If property exists, return it (wrapped in proxy if it's an object)
if (value !== undefined) {
// Recursively wrap objects/arrays in proxy
if (typeof value === 'object' && value !== null && !(value instanceof Date) && !(value instanceof RegExp)) {
return createSafeStateProxy(value);
}
return value;
}
// Property is undefined - return empty string as safe default
// This prevents "undefined" from appearing in string concatenations
return "";
}
});
};
/**
* Evaluate a JavaScript expression with state context
*
* @param expression - JavaScript expression as string (e.g., "page.user.age + 1")
* @param context - Evaluation context with state object
* @returns Evaluated result
* @throws Error if evaluation fails
*
* @example
* evaluateExpression("page.user.name", { state: { page: { user: { name: "Jon" } }, global: {} } })
* // Returns: "Jon"
*
* evaluateExpression("global.counter + 1", { state: { page: {}, global: { counter: 25 } } })
* // Returns: 26
*
* evaluateExpression("state.page.user.name.toUpperCase()", { state: { page: { user: { name: "Jon" } }, global: {} } })
* // Returns: "JON"
*/
export const evaluateExpression = (
expression: string,
context: EvaluationContext
): any => {
// Validate input
if (!expression || typeof expression !== 'string') {
throw new Error('Expression must be a non-empty string');
}
const trimmedExpression = expression.trim();
if (!trimmedExpression) {
throw new Error('Expression cannot be empty');
}
try {
// Wrap state and aliases in safe proxies that return defaults for undefined properties
// This prevents "undefined" from appearing in expressions
// Example: state.page.user.mobileNumber returns "" instead of undefined
const safeState = createSafeStateProxy(context.state);
const safePage = createSafeStateProxy(context?.state?.page ?? {});
const safeGlobal = createSafeStateProxy(context?.state?.global ?? {});
// Create a function with scoped aliases in scope
// This is safe because:
// 1. This is a builder tool (not end-user facing)
// 2. Expressions are written by trusted builder users
// 3. Expressions run in isolated preview context
const func = new Function('state', 'page', 'global', `return (${trimmedExpression});`);
return func(safeState, safePage, safeGlobal);
} catch (error: any) {
throw new Error(`Expression evaluation failed: ${error?.message || String(error)}`);
}
};
/**
* Validate if an expression is syntactically correct (without executing it)
*
* @param expression - JavaScript expression to validate
* @returns true if valid, false otherwise
*/
export const validateExpression = (expression: string): boolean => {
if (!expression || typeof expression !== 'string') {
return false;
}
const trimmedExpression = expression.trim();
if (!trimmedExpression) {
return false;
}
try {
// Try to create the function without executing it
new Function('state', 'page', 'global', `return (${trimmedExpression});`);
return true;
} catch {
return false;
}
};

View File

@ -0,0 +1,92 @@
import type { ComponentNode, PagesData, StateData } from '../types/component.types';
import { v4 as uuidv4 } from 'uuid';
const STATE_VERSION = 1;
const PAGE_STATE_VERSION = 1;
const safeObject = (value: unknown): Record<string, any> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, any>) : {};
const createStateData = (state: unknown): StateData => {
return {
state: safeObject(state),
version: STATE_VERSION,
};
};
const createPageStateData = (pageStateById: Record<string, unknown> = {}): Record<string, StateData> => {
const entries = Object.entries(pageStateById).map(([pageId, state]) => {
return [pageId, { state: safeObject(state), version: PAGE_STATE_VERSION } satisfies StateData] as const;
});
return Object.fromEntries(entries);
};
export const exportToJSON = (
pagesData: PagesData,
globalState: Record<string, any> = {},
pageStateById: Record<string, Record<string, any>> = {}
): string => {
const payload: PagesData = {
...pagesData,
stateData: createStateData(globalState),
pageStateData: createPageStateData(pageStateById),
};
return JSON.stringify(payload, null, 2);
};
export const importFromJSON = (json: string): PagesData | null => {
try {
const parsed = JSON.parse(json);
// Check if it's the new PagesData format
if (parsed.pages && Array.isArray(parsed.pages)) {
return parsed as PagesData;
}
// Backward compatibility: if it's just a ComponentNode, convert to PagesData
if (parsed.id && parsed.type) {
const defaultPage = {
id: uuidv4(),
name: 'Home',
route: '/',
root: parsed as ComponentNode,
isDefault: true,
};
return {
pages: [defaultPage],
currentPageId: defaultPage.id,
};
}
return null;
} catch (error) {
console.error('Failed to parse JSON:', error);
return null;
}
};
export const deserializeStateData = (pagesData: PagesData | null): Record<string, any> | null => {
if (!pagesData?.stateData?.state) {
return null;
}
return pagesData.stateData.state;
};
export const deserializePageStateData = (pagesData: PagesData | null): Record<string, Record<string, any>> | null => {
const pageStateData = pagesData?.pageStateData;
if (!pageStateData || typeof pageStateData !== 'object') {
return null;
}
const byPageId: Record<string, Record<string, any>> = {};
for (const [pageId, stateData] of Object.entries(pageStateData)) {
if (!stateData || typeof stateData !== 'object') continue;
const state = (stateData as any).state;
if (state && typeof state === 'object') {
byPageId[pageId] = state as Record<string, any>;
}
}
return byPageId;
};

View File

@ -0,0 +1,220 @@
import { parseScopedPath } from '../state/scopedPath';
/**
* Parse all state bindings from text
* Supports flexible whitespace: both {{ property }} and {{property}} formats
*/
export const parseStateBindings = (text: string): string[] => {
// Match {{ property }} or {{property}} patterns
const regex = /\{\{\s*([^}]+?)\s*\}\}/g;
const matches: string[] = [];
let match;
while ((match = regex.exec(text)) !== null) {
const property = match[1].trim();
if (property) {
matches.push(property);
}
}
return matches;
};
/**
* Resolve nested property path from state object
* Returns empty string if property doesn't exist
*/
export const resolveStateValue = (state: any, path: string): any => {
if (!path || !state) {
return '';
}
const parts = path.split('.');
let current: any = state;
for (const part of parts) {
if (current == null || typeof current !== 'object') {
return '';
}
current = current[part];
if (current === undefined) {
return '';
}
}
if (current && typeof current === 'object' && typeof current.__ref === 'string') {
return resolveStateValue(state, current.__ref);
}
// Convert to string for display, but preserve null/undefined as empty string
if (current == null) {
return '';
}
return String(current);
};
/**
* Interpolate text by replacing all {{ property }} bindings with actual values
* Strict mode: all bindings must be scoped (`page.*` or `global.*`)
*/
export const interpolateText = (
text: string,
state: any,
options?: { strict?: boolean }
): string => {
if (!text) {
return text || '';
}
const strict = options?.strict ?? true;
// Match {{ property }} or {{property}} patterns and replace
return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, propertyPath) => {
const trimmedPath = propertyPath.trim();
if (!trimmedPath) {
if (strict) {
throw new Error('Empty state binding is not allowed');
}
return match;
}
let parsedPath;
try {
parsedPath = parseScopedPath(trimmedPath);
} catch (error) {
if (!strict) {
return match;
}
const message = error instanceof Error ? error.message : 'Invalid binding path';
throw new Error(`Invalid state binding "${trimmedPath}": ${message}`);
}
return resolveStateValue(state, parsedPath.fullPath);
});
};
/**
* Get keys of an object
* Returns empty array for non-objects, arrays, null, undefined
*/
export const getObjectKeys = (obj: any): string[] => {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return [];
}
return Object.keys(obj);
};
/**
* Navigate to a nested path in an object
* Returns the value at the path, or undefined if not found
*/
export const navigatePath = (obj: any, path: string): any => {
if (!path || !obj) {
return obj;
}
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null || typeof current !== 'object') {
return undefined;
}
current = current[part];
if (current === undefined) {
return undefined;
}
}
return current;
};
/**
* Get suggestions for autocomplete based on current path
* Uses a scoped state object (`page` + `global`)
*
* @param unifiedState - Object containing available scoped bindings
* @param currentPath - Current path being typed (e.g., "page.user.cart")
* @returns Array of property names for the next level
*/
export const getStateSuggestions = (
unifiedState: any,
currentPath?: string
): string[] => {
if (!unifiedState || typeof unifiedState !== 'object') {
return [];
}
// If no path, return top-level keys
if (!currentPath) {
return getObjectKeys(unifiedState);
}
// Navigate to the path and return keys of that object
const target = navigatePath(unifiedState, currentPath);
return getObjectKeys(target);
};
/**
* Recursively resolve {{ }} bindings in an object
* Walks through all values and resolves bindings using interpolateText
*
* @param obj - Object to resolve (can be nested objects/arrays)
* @param state - Global state for resolving bindings
* @returns Resolved object with all bindings replaced
*/
const resolveObjectBindings = (obj: any, state: any): any => {
// Base case: primitive values
if (obj === null || obj === undefined) {
return obj;
}
// If it's a string, resolve any bindings
if (typeof obj === 'string') {
return interpolateText(obj, state);
}
// If it's an array, recursively resolve each element
if (Array.isArray(obj)) {
return obj.map((item) => resolveObjectBindings(item, state));
}
// If it's an object, recursively resolve each property
if (typeof obj === 'object') {
const resolved: Record<string, any> = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
resolved[key] = resolveObjectBindings(obj[key], state);
}
}
return resolved;
}
// Other types (number, boolean, etc.) - return as is
return obj;
};
/**
* Parse JSON request body and resolve all {{ page.* }} / {{ global.* }} bindings
*
* @param requestBodyJson - JSON string containing request body (may have {{ }} bindings)
* @param state - Scoped runtime state object
* @returns Resolved request body object
* @throws Error if JSON is invalid
*/
export const resolveRequestBody = (requestBodyJson: string, state: any): any => {
// Empty or whitespace-only strings return empty object
if (!requestBodyJson || !requestBodyJson.trim()) {
return {};
}
let parsed: any;
try {
parsed = JSON.parse(requestBodyJson);
} catch (error) {
throw new Error(`Failed to parse request body JSON: ${error instanceof Error ? error.message : String(error)}`);
}
return resolveObjectBindings(parsed, state);
};

View File

@ -0,0 +1,41 @@
import type { CSSProperties } from 'react';
import type { ComponentType } from '../types/component.types';
/**
* Normalizes component styles to prevent unwanted flexbox stretching.
* For components without explicit width in flex containers, applies
* alignSelf: 'flex-start' to prevent stretching BUT only when the parent
* has not already set a non-stretch alignment (e.g. alignItems: 'center').
* When the parent explicitly aligns its children, we trust that and leave
* alignSelf untouched so the parent's intent is preserved in both editor and preview.
*/
export const normalizeComponentStyles = (
componentType: ComponentType,
styles: CSSProperties,
parentType?: ComponentType | null,
parentStyles?: CSSProperties | null,
): CSSProperties => {
// Check if parent is a flex container
const isInFlexContainer = parentType === 'row' || parentType === 'column';
if (isInFlexContainer) {
const hasExplicitWidth = styles.width !== undefined && styles.width !== null;
const hasExplicitAlignSelf = styles.alignSelf !== undefined && styles.alignSelf !== null;
// Only inject alignSelf: flex-start when the parent hasn't explicitly overridden
// the default 'stretch' behaviour. If the parent sets alignItems to 'center',
// 'flex-end', 'flex-start', etc., we leave children alone so that intent is honoured.
const parentAlignItems = parentStyles?.alignItems;
const parentIsStretching =
!parentAlignItems || parentAlignItems === 'stretch' || parentAlignItems === 'normal';
if (!hasExplicitWidth && !hasExplicitAlignSelf && componentType !== 'input' && parentIsStretching) {
return {
...styles,
alignSelf: 'flex-start',
};
}
}
return styles;
};

View File

@ -0,0 +1,116 @@
export interface NormalizedWorkflowResultKey {
relativeKey: string;
fullPath: string;
}
const SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const ensureValidKey = (key: string): void => {
if (!key) {
throw new Error('Result key is required');
}
if (key.startsWith('state.') || key.startsWith('workflows.') || key.startsWith('global.workflows.')) {
throw new Error('Result key must be relative (do not include state./workflows./global.workflows. prefixes)');
}
if (key.startsWith('.') || key.endsWith('.') || key.includes('..')) {
throw new Error('Result key must not start or end with a dot, or contain consecutive dots');
}
const segments = key.split('.');
for (const segment of segments) {
if (!SEGMENT_PATTERN.test(segment)) {
throw new Error('Each segment must start with a letter or underscore and contain only letters, numbers, or underscores');
}
}
};
export const normalizeWorkflowResultKey = (input: string): NormalizedWorkflowResultKey => {
if (typeof input !== 'string') {
throw new Error('Result key must be a string');
}
const key = input.trim();
ensureValidKey(key);
return {
relativeKey: key,
fullPath: `global.workflows.${key}`,
};
};
const tokenize = (value: string): string[] =>
value
.split(/[^a-zA-Z0-9]+/)
.map((token) => token.trim())
.filter(Boolean);
const toLowerCamel = (value: string): string => {
const tokens = tokenize(value);
if (tokens.length === 0) {
return '';
}
const [first, ...rest] = tokens;
const firstLower = first.toLowerCase();
const restCamel = rest.map((token) => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase());
return [firstLower, ...restCamel].join('');
};
const stableHash = (input: string): number => {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
}
return hash;
};
const buildFallbackKey = (seed: string): string => {
const suffix = stableHash(seed || 'workflow').toString(36).slice(0, 6) || '0';
const candidate = `workflow.result.r${suffix}`;
try {
normalizeWorkflowResultKey(candidate);
return candidate;
} catch {
return 'workflow.result.r0';
}
};
export const suggestWorkflowResultKeyFromName = (workflowName: string): string => {
const trimmed = typeof workflowName === 'string' ? workflowName.trim() : '';
if (!trimmed) {
return buildFallbackKey('workflow');
}
const parts = trimmed
.split(':')
.map((part) => part.trim())
.filter(Boolean);
const namespaceParts = parts.length > 1 ? parts.slice(0, -1) : [];
const actionPart = parts.length > 0 ? parts[parts.length - 1] : trimmed;
const segments = [
...namespaceParts.map((part) => toLowerCamel(part)).filter(Boolean),
toLowerCamel(actionPart),
].filter(Boolean);
const candidate = segments.join('.');
if (candidate) {
try {
normalizeWorkflowResultKey(candidate);
return candidate;
} catch {
// fall through to fallback
}
}
return buildFallbackKey(trimmed);
};
export const getWorkflowResultKeyPreview = (input: string): string => {
if (typeof input !== 'string') {
return '';
}
return input.trim();
};

14
src/index.css Normal file
View File

@ -0,0 +1,14 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
* {
box-sizing: border-box;
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import { App } from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,21 @@
import React from 'react';
import { RendererRoutes } from './RendererRoutes';
// Ensure component definitions are registered once per bundle load.
import '../common/registry/definitions';
export interface RendererAppProps {
json: string;
workflow?: { executeUrl: string };
initialRoute?: string;
}
export const RendererApp: React.FC<RendererAppProps> = ({ json, workflow, initialRoute }) => {
return (
<RendererRoutes
json={json}
workflow={workflow}
initialRoute={initialRoute ?? window.location.pathname}
/>
);
};

View File

@ -0,0 +1,94 @@
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import type { PagesData } from '../common/types/component.types';
import { importFromJSON } from '../common/utils/serialization';
import { formatScopedBindingViolations, validatePagesDataScopedBindings } from '../common/state/validateScopedBindings';
import { RendererRuntimeProvider } from './runtime';
import { Preview } from './components/Preview';
// Ensure component definitions are registered once per bundle load.
import '../common/registry/definitions';
export interface RendererRoutesProps {
json: string;
workflow?: { executeUrl: string };
initialRoute?: string;
}
export const RendererRoutes: React.FC<RendererRoutesProps> = ({ json, workflow, initialRoute }) => {
const parseResult = useMemo<{ pagesData: PagesData | null; error: string | null }>(() => {
const parsed = importFromJSON(json);
if (!parsed || !Array.isArray(parsed.pages)) {
return { pagesData: null, error: 'Invalid renderer JSON.' };
}
const violations = validatePagesDataScopedBindings(parsed);
if (violations.length > 0) {
return {
pagesData: null,
error: `Invalid scoped bindings detected:\n${formatScopedBindingViolations(violations)}`,
};
}
return { pagesData: parsed, error: null };
}, [json]);
const defaultRoute = useMemo(() => {
if (!parseResult.pagesData) return initialRoute || '/';
const defaultPage = parseResult.pagesData.pages.find((p) => p.isDefault) || parseResult.pagesData.pages[0];
return initialRoute || defaultPage?.route || '/';
}, [parseResult.pagesData, initialRoute]);
const getHashRoute = (): string | null => {
const hash = window.location.hash;
return hash ? hash.slice(1) : null;
};
const [currentRoute, setCurrentRoute] = useState<string>(
getHashRoute() || defaultRoute
);
// Set initial hash on mount
useEffect(() => {
if (!window.location.hash) {
window.location.hash = defaultRoute;
}
}, []);
const handleNavigate = useCallback((route: string) => {
window.location.hash = route;
setCurrentRoute(route);
}, []);
// Sync state with browser back/forward
useEffect(() => {
const onHashChange = () => {
const route = getHashRoute();
if (route) setCurrentRoute(route);
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
if (!parseResult.pagesData || !Array.isArray(parseResult.pagesData.pages)) {
return (
<div style={{ padding: 16, fontFamily: 'system-ui, sans-serif', color: '#333', whiteSpace: 'pre-wrap' }}>
{parseResult.error || 'Invalid renderer JSON.'}
</div>
);
}
return (
<RendererRuntimeProvider
pagesData={parseResult.pagesData}
workflow={workflow}
currentRoute={currentRoute}
onNavigate={handleNavigate}
>
<Preview
pages={parseResult.pagesData.pages}
currentRoute={currentRoute}
onNavigate={handleNavigate}
/>
</RendererRuntimeProvider>
);
};

View File

@ -0,0 +1,52 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import type { Page } from '../../common/types/component.types';
interface NavigationHeaderProps {
pages: Page[];
}
export const NavigationHeader: React.FC<NavigationHeaderProps> = ({ pages }) => {
const location = useLocation();
return (
<nav
style={{
backgroundColor: '#fff',
borderBottom: '1px solid #ddd',
padding: '12px 24px',
}}
>
<div
style={{
display: 'flex',
gap: '16px',
alignItems: 'center',
}}
>
{pages.map((page) => {
const isActive = location.pathname === page.route;
return (
<Link
key={page.id}
to={page.route}
style={{
padding: '8px 16px',
textDecoration: 'none',
color: isActive ? '#007bff' : '#333',
backgroundColor: isActive ? '#e3f2fd' : 'transparent',
borderRadius: '4px',
fontWeight: isActive ? '600' : '400',
fontSize: '14px',
border: isActive ? '1px solid #007bff' : '1px solid transparent',
transition: 'all 0.2s',
}}
>
{page.name}
</Link>
);
})}
</div>
</nav>
);
};

View File

@ -0,0 +1,92 @@
import React from 'react';
import type { CSSProperties } from 'react';
import type { ComponentNode, ComponentType, Page } from '../../common/types/component.types';
import { ComponentRenderer } from '../../common/utils/ComponentRenderer';
interface PreviewProps {
pages: Page[];
currentRoute: string;
onNavigate: (route: string) => void;
}
const PreviewComponent: React.FC<{
component: ComponentNode;
parentType?: ComponentType | null;
parentStyles?: CSSProperties | null;
}> = ({ component, parentType = null, parentStyles = null }) => {
const renderChildren = (
children: ComponentNode[],
currentParentType: ComponentType,
currentParentStyles?: CSSProperties,
) => {
return children.map((child) => (
<PreviewComponent
key={child.id}
component={child}
parentType={currentParentType}
parentStyles={currentParentStyles ?? null}
/>
));
};
return (
<ComponentRenderer
component={component}
isSelected={null}
onSelect={() => {}}
renderChildren={renderChildren}
previewMode={true}
parentType={parentType}
parentStyles={parentStyles}
/>
);
};
const PageContent: React.FC<{ root: ComponentNode | null }> = ({ root }) => {
return (
<div
style={{
height: '100%',
overflow: 'auto',
backgroundColor: '#fff',
}}
>
{root ? (
<PreviewComponent component={root} />
) : (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999',
fontSize: '16px',
}}
>
No components to preview. Add components in the editor.
</div>
)}
</div>
);
};
export const Preview: React.FC<PreviewProps> = ({ pages, currentRoute }) => {
const defaultPage = pages.find((p) => p.isDefault) || pages[0];
const activePage = pages.find((p) => p.route === currentRoute) || defaultPage;
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ flex: 1, overflow: 'hidden' }}>
<PageContent root={activePage?.root ?? null} />
</div>
</div>
);
};

5
src/renderer/index.ts Normal file
View File

@ -0,0 +1,5 @@
export { RendererApp } from './RendererApp';
export { RendererRoutes } from './RendererRoutes';
export { NavigationHeader } from './components/NavigationHeader';
export { importFromJSON } from '../common/utils/serialization';

View File

@ -0,0 +1,247 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { EventHandler, PagesData, ComponentNode } from '../../common/types/component.types';
import type { ComponentEventType } from '../../common/registry/types';
import { interpolateText } from '../../common/utils/stateBinding';
import { updateStateAtPath } from '../../common/state/statePathUtils';
import { parseScopedPath } from '../../common/state/scopedPath';
import { EventExecutor } from '../../common/events';
import { RuntimeContext } from '../../common/runtime';
import type { RuntimeContextValue } from '../../common/runtime';
const isPathValidForNested = (path: string): boolean => {
if (!path || typeof path !== 'string') return false;
const trimmed = path.trim();
if (!trimmed || trimmed.startsWith('.') || trimmed.endsWith('.') || trimmed.includes('..')) return false;
const pathPattern = /^[a-zA-Z0-9_. -]+$/;
return pathPattern.test(trimmed);
};
const safeObject = (value: unknown): Record<string, any> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, any>) : {};
const deserializePageStateSnapshot = (pagesData: PagesData): Record<string, Record<string, any>> => {
const raw = pagesData.pageStateData;
if (!raw || typeof raw !== 'object') return {};
const byPageId: Record<string, Record<string, any>> = {};
for (const [pageId, stateData] of Object.entries(raw)) {
const state = (stateData as any)?.state;
byPageId[pageId] = safeObject(state);
}
return byPageId;
};
export interface RendererRuntimeProviderProps {
children: React.ReactNode;
pagesData: PagesData;
workflow?: { executeUrl: string };
currentRoute: string;
onNavigate: (route: string) => void;
}
export const RendererRuntimeProvider: React.FC<RendererRuntimeProviderProps> = ({
children,
pagesData,
workflow,
currentRoute,
onNavigate,
}) => {
const pages = pagesData.pages ?? [];
const [globalState, setGlobalState] = useState<Record<string, any>>(() => safeObject(pagesData.stateData?.state));
const [pageStateById, setPageStateById] = useState<Record<string, Record<string, any>>>(() =>
deserializePageStateSnapshot(pagesData)
);
const currentPageId = useMemo(() => {
const matched = pages.find((p) => p.route === currentRoute);
if (matched) return matched.id;
const fallback = pages.find((p) => p.isDefault) || pages[0];
return fallback?.id ?? null;
}, [currentRoute, pages]);
const currentPageState = useMemo(() => {
if (!currentPageId) return {};
return pageStateById[currentPageId] ?? {};
}, [currentPageId, pageStateById]);
const scopedState = useMemo(() => {
return {
page: currentPageState,
global: globalState,
};
}, [globalState, currentPageState]);
const setScopedStateValueDirect = useCallback(
(path: string, value: any): boolean => {
let parsedPath;
try {
parsedPath = parseScopedPath(path);
} catch (error) {
console.error(error);
return false;
}
if (!isPathValidForNested(parsedPath.relativePath)) return false;
const parts = parsedPath.relativePath.split('.');
if (parsedPath.scope === 'page') {
if (!currentPageId) return false;
setPageStateById((prev) => {
const current = prev[currentPageId] ?? {};
const next = updateStateAtPath(current, parts, value);
return { ...prev, [currentPageId]: next };
});
return true;
}
setGlobalState((prev) => updateStateAtPath(prev, parts, value));
return true;
},
[currentPageId]
);
const setScopedStateValue = useCallback(
(path: string, value: any): boolean => {
let parsedPath;
try {
parsedPath = parseScopedPath(path);
} catch (error) {
console.error(error);
return false;
}
if (!isPathValidForNested(parsedPath.relativePath)) return false;
let processedValue: any = value;
if (typeof value === 'string') {
const trimmedValue = value.trim();
if (trimmedValue.toLowerCase() === 'true') {
processedValue = true;
} else if (trimmedValue.toLowerCase() === 'false') {
processedValue = false;
} else if (trimmedValue === 'null') {
processedValue = null;
} else {
const numberMatch = trimmedValue.match(/^-?\d+\.?\d*$/);
if (numberMatch) {
processedValue = Number(trimmedValue);
if (isNaN(processedValue)) {
processedValue = value;
}
} else {
processedValue = value;
}
}
}
return setScopedStateValueDirect(parsedPath.fullPath, processedValue);
},
[setScopedStateValueDirect]
);
const setScopedStateValueRaw = useCallback(
(key: string, value: any): boolean => {
let parsedPath;
try {
parsedPath = parseScopedPath(key);
} catch (error) {
console.error(error);
return false;
}
if (!parsedPath.relativePath) return false;
if (parsedPath.scope === 'page') {
if (!currentPageId) return false;
setPageStateById((prev) => {
const current = prev[currentPageId] ?? {};
return { ...prev, [currentPageId]: { ...current, [parsedPath.relativePath]: value } };
});
return true;
}
setGlobalState((prev) => ({ ...prev, [parsedPath.relativePath]: value }));
return true;
},
[currentPageId]
);
const navigateToPage = useCallback(
(pageId: string) => {
const page = pages.find((p) => p.id === pageId);
if (page) {
onNavigate(page.route);
}
},
[onNavigate, pages]
);
const eventExecutor = useMemo(() => new EventExecutor({ workflow }), [workflow?.executeUrl]);
useEffect(() => {
eventExecutor.setContext({
setStateValue: setScopedStateValue,
setStateValueRaw: setScopedStateValueRaw,
setStateValueDirect: setScopedStateValueDirect,
navigateToPage,
pages,
state: scopedState,
});
}, [
eventExecutor,
navigateToPage,
pages,
scopedState,
setScopedStateValue,
setScopedStateValueDirect,
setScopedStateValueRaw,
]);
const resolveText = useCallback(
(text: string): string => {
return interpolateText(text, scopedState);
},
[scopedState]
);
const executeHandlers = useCallback(
async (handlers: EventHandler[]) => {
if (handlers.length === 0) return;
await eventExecutor.executeWithErrorHandling(handlers);
},
[eventExecutor]
);
const dispatchEvent = useCallback(
async (_eventType: ComponentEventType, component: ComponentNode, handlersKey = 'eventHandlers') => {
const handlers = (component.props?.[handlersKey] as EventHandler[]) || [];
if (handlers.length === 0) return;
await eventExecutor.executeWithErrorHandling(handlers);
},
[eventExecutor]
);
const setStateValue = useCallback(
(path: string, value: any) => {
setScopedStateValueDirect(path, value);
},
[setScopedStateValueDirect]
);
const contextValue = useMemo<RuntimeContextValue>(
() => ({
state: scopedState,
resolveText,
executeHandlers,
dispatchEvent,
pages,
navigateToPage,
setStateValue,
previewMode: true,
}),
[dispatchEvent, executeHandlers, navigateToPage, pages, resolveText, scopedState, setStateValue]
);
return <RuntimeContext.Provider value={contextValue}>{children}</RuntimeContext.Provider>;
};

View File

@ -0,0 +1,2 @@
export { RendererRuntimeProvider } from './RendererRuntimeProvider';

View File

@ -0,0 +1,20 @@
import type { Page } from '../../common/types/component.types';
import type { NavigateFunction } from 'react-router-dom';
/**
* Creates a navigation handler function for event handlers
* Finds page by ID and navigates to its route
*/
export const createNavigateHandler = (
pages: Page[],
navigate: NavigateFunction
): ((pageId: string) => void) => {
return (pageId: string) => {
const page = pages.find((p) => p.id === pageId);
if (page) {
navigate(page.route);
} else {
console.warn(`[Navigation] Page with ID ${pageId} not found`);
}
};
};

36
tsconfig.app.json Normal file
View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": [
"vite/client"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
],
"exclude": [
"**/*.test.ts",
"**/*.spec.ts"
]
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

28
tsconfig.node.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": [
"ES2023"
],
"module": "ESNext",
"types": [
"node"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"vite.config.ts"
]
}

6
vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});