diff --git a/src/components/json-page-renderer/utils.tsx b/src/components/json-page-renderer/utils.tsx index 3729351..226c9bb 100644 --- a/src/components/json-page-renderer/utils.tsx +++ b/src/components/json-page-renderer/utils.tsx @@ -1,12 +1,11 @@ import * as Icons from '@phosphor-icons/react' +import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers' export function resolveBinding(binding: string, data: Record): any { - try { - const func = new Function(...Object.keys(data), `return ${binding}`) - return func(...Object.values(data)) - } catch { - return binding - } + return evaluateBindingExpression(binding, data, { + fallback: binding, + label: 'json-page-renderer binding', + }) } export function getIcon(iconName: string, props?: any) { diff --git a/src/components/orchestration/ComponentRenderer.tsx b/src/components/orchestration/ComponentRenderer.tsx index 45d38ef..4e2264a 100644 --- a/src/components/orchestration/ComponentRenderer.tsx +++ b/src/components/orchestration/ComponentRenderer.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react' import { ComponentSchema as ComponentSchemaType } from '@/types/page-schema' import { getUIComponent } from '@/lib/json-ui/component-registry' +import { evaluateConditionExpression, evaluateTransformExpression } from '@/lib/json-ui/expression-helpers' interface ComponentRendererProps { schema: ComponentSchemaType @@ -17,13 +18,10 @@ export function ComponentRenderer({ schema, context, onEvent }: ComponentRendere } if (schema.condition) { - try { - const conditionFn = new Function('context', `return ${schema.condition}`) - if (!conditionFn(context)) { - return null - } - } catch (error) { - console.error(`Condition evaluation failed for ${schema.id}:`, error) + const conditionMet = evaluateConditionExpression(schema.condition, context, { + label: `component condition (${schema.id})`, + }) + if (!conditionMet) { return null } } @@ -34,13 +32,10 @@ export function ComponentRenderer({ schema, context, onEvent }: ComponentRendere schema.bindings.forEach(binding => { const value = getNestedValue(context, binding.source) if (binding.transform) { - try { - const transformFn = new Function('value', 'context', `return ${binding.transform}`) - props[binding.target] = transformFn(value, context) - } catch (error) { - console.error(`Transform failed for ${binding.target}:`, error) - props[binding.target] = value - } + props[binding.target] = evaluateTransformExpression(binding.transform, value, context, { + fallback: value, + label: `binding transform (${binding.target})`, + }) } else { props[binding.target] = value } diff --git a/src/hooks/json-ui/use-json-renderer.ts b/src/hooks/json-ui/use-json-renderer.ts index 970e5c8..0e2c730 100644 --- a/src/hooks/json-ui/use-json-renderer.ts +++ b/src/hooks/json-ui/use-json-renderer.ts @@ -1,17 +1,15 @@ import { useMemo } from 'react' -import { ComponentSchema } from '@/types/json-ui' +import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers' export function useJSONRenderer() { const resolveBinding = useMemo(() => { return (binding: string, data: Record): any => { if (!binding) return undefined - try { - const func = new Function(...Object.keys(data), `return ${binding}`) - return func(...Object.values(data)) - } catch { - return binding - } + return evaluateBindingExpression(binding, data, { + fallback: binding, + label: 'json renderer binding', + }) } }, []) diff --git a/src/hooks/orchestration/use-page.ts b/src/hooks/orchestration/use-page.ts index fa8608f..e6d9fa0 100644 --- a/src/hooks/orchestration/use-page.ts +++ b/src/hooks/orchestration/use-page.ts @@ -1,11 +1,12 @@ import { useState, useEffect, useMemo } from 'react' -import { PageSchema, DataSource } from '@/types/page-schema' +import { PageSchema } from '@/types/page-schema' import { useFiles } from '../data/use-files' import { useModels } from '../data/use-models' import { useComponents } from '../data/use-components' import { useWorkflows } from '../data/use-workflows' import { useLambdas } from '../data/use-lambdas' import { useActions } from './use-actions' +import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers' export function usePage(schema: PageSchema) { const files = useFiles() @@ -46,12 +47,10 @@ export function usePage(schema: PageSchema) { schema.data.forEach(source => { if (source.type === 'computed' && source.compute) { - try { - const computeFn = new Function('context', `return ${source.compute}`) - computed[source.id] = computeFn(dataContext) - } catch (error) { - console.error(`Failed to compute ${source.id}:`, error) - } + computed[source.id] = evaluateBindingExpression(source.compute, dataContext, { + fallback: undefined, + label: `computed data (${source.id})`, + }) } else if (source.type === 'static' && source.defaultValue !== undefined) { computed[source.id] = source.defaultValue } diff --git a/src/lib/json-ui/component-renderer.tsx b/src/lib/json-ui/component-renderer.tsx index 281b3d9..b348f1e 100644 --- a/src/lib/json-ui/component-renderer.tsx +++ b/src/lib/json-ui/component-renderer.tsx @@ -1,7 +1,8 @@ import { createElement, useMemo, Fragment } from 'react' import { UIComponent, Binding, ComponentRendererProps, EventHandler, JSONEventDefinition, JSONEventMap } from '@/types/json-ui' import { getUIComponent } from './component-registry' -import { resolveDataBinding, evaluateCondition } from './utils' +import { resolveDataBinding } from './utils' +import { evaluateConditionExpression } from './expression-helpers' function resolveBinding( binding: Binding, @@ -100,7 +101,7 @@ export function ComponentRenderer({ component, data, context = {}, state, onEven const conditionMet = !handler.condition || (typeof handler.condition === 'function' ? handler.condition(mergedData as Record) - : evaluateCondition(handler.condition, mergedData as Record)) + : evaluateConditionExpression(handler.condition, mergedData as Record, { label: 'event handler condition' })) if (conditionMet) { const eventPayload = typeof e === 'object' && e !== null ? Object.assign(e as Record, context) @@ -195,7 +196,7 @@ export function ComponentRenderer({ component, data, context = {}, state, onEven const renderConditionalContent = (renderContext: Record) => { if (!component.conditional) return undefined - const conditionMet = evaluateCondition(component.conditional.if, { ...data, ...renderContext } as Record) + const conditionMet = evaluateConditionExpression(component.conditional.if, { ...data, ...renderContext } as Record, { label: `component conditional (${component.id})` }) if (conditionMet) { if (component.conditional.then !== undefined) { return renderBranch(component.conditional.then as UIComponent | (UIComponent | string)[] | string, renderContext) diff --git a/src/lib/json-ui/expression-helpers.ts b/src/lib/json-ui/expression-helpers.ts new file mode 100644 index 0000000..0cc6795 --- /dev/null +++ b/src/lib/json-ui/expression-helpers.ts @@ -0,0 +1,122 @@ +import { evaluateCondition, evaluateExpression } from './expression-evaluator' + +const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/ +const NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/ +const STRING_PATTERN = /^(['"]).*\1$/ + +interface EvaluationOptions { + fallback?: any + label?: string + event?: any +} + +const isSupportedExpression = (expression: string) => { + if (expression === 'event' || expression === 'data') return true + if (expression.startsWith('data.') || expression.startsWith('event.')) return true + if (expression === 'Date.now()') return true + if (STRING_PATTERN.test(expression)) return true + if (NUMBER_PATTERN.test(expression)) return true + if (['true', 'false', 'null', 'undefined'].includes(expression)) return true + return false +} + +const normalizeExpression = (expression: string, coerceIdentifier = true) => { + const trimmed = expression.trim() + if (coerceIdentifier && IDENTIFIER_PATTERN.test(trimmed) && trimmed !== 'data' && trimmed !== 'event') { + return `data.${trimmed}` + } + return trimmed +} + +const isSupportedCondition = (condition: string) => { + return ( + /^data\.[a-zA-Z0-9_.]+\s*>\s*.+$/.test(condition) + || /^data\.[a-zA-Z0-9_.]+\.length\s*>\s*.+$/.test(condition) + || /^data\.[a-zA-Z0-9_.]+\s*===\s*['"].+['"]$/.test(condition) + || /^data\.[a-zA-Z0-9_.]+\s*!=\s*null$/.test(condition) + ) +} + +const normalizeCondition = (condition: string) => { + const trimmed = condition.trim() + if (trimmed.startsWith('data.') || trimmed.startsWith('event.')) { + return trimmed + } + + const lengthMatch = trimmed.match(/^([a-zA-Z0-9_.]+)\.length\s*>\s*(.+)$/) + if (lengthMatch) { + return `data.${lengthMatch[1]}.length > ${lengthMatch[2]}` + } + + const gtMatch = trimmed.match(/^([a-zA-Z0-9_.]+)\s*>\s*(.+)$/) + if (gtMatch) { + return `data.${gtMatch[1]} > ${gtMatch[2]}` + } + + const eqMatch = trimmed.match(/^([a-zA-Z0-9_.]+)\s*===\s*(['"].+['"])$/) + if (eqMatch) { + return `data.${eqMatch[1]} === ${eqMatch[2]}` + } + + const nullMatch = trimmed.match(/^([a-zA-Z0-9_.]+)\s*!=\s*null$/) + if (nullMatch) { + return `data.${nullMatch[1]} != null` + } + + return trimmed +} + +const warnUnsupported = (kind: string, expression: string, label?: string) => { + const labelText = label ? ` for ${label}` : '' + console.warn(`[json-ui] Unsupported ${kind} expression${labelText}: "${expression}"`) +} + +export const evaluateBindingExpression = ( + expression: string | undefined, + data: Record, + options: EvaluationOptions = {} +) => { + if (!expression) return undefined + const normalized = normalizeExpression(expression) + if (!isSupportedExpression(normalized)) { + warnUnsupported('binding', expression, options.label) + return options.fallback ?? expression + } + return evaluateExpression(normalized, { data, event: options.event }) +} + +export const evaluateTransformExpression = ( + expression: string | undefined, + value: any, + dataContext: Record = {}, + options: EvaluationOptions = {} +) => { + if (!expression) return value + const normalized = normalizeExpression(expression) + if (!isSupportedExpression(normalized)) { + warnUnsupported('transform', expression, options.label) + return options.fallback ?? value + } + + const valueContext = typeof value === 'object' && value !== null ? value : {} + const mergedData = { + ...dataContext, + ...valueContext, + value, + } + + return evaluateExpression(normalized, { data: mergedData, event: options.event }) +} + +export const evaluateConditionExpression = ( + condition: string | undefined, + data: Record, + options: EvaluationOptions = {} +) => { + if (!condition) return true + const normalized = normalizeCondition(condition) + if (!isSupportedCondition(normalized)) { + warnUnsupported('condition', condition, options.label) + } + return evaluateCondition(normalized, { data, event: options.event }) +} diff --git a/src/lib/json-ui/renderer.tsx b/src/lib/json-ui/renderer.tsx index e532df7..3f24085 100644 --- a/src/lib/json-ui/renderer.tsx +++ b/src/lib/json-ui/renderer.tsx @@ -9,7 +9,8 @@ import type { UIComponent, } from './types' import { getUIComponent } from './component-registry' -import { resolveDataBinding, evaluateCondition } from './utils' +import { resolveDataBinding } from './utils' +import { evaluateConditionExpression } from './expression-helpers' import { cn } from '@/lib/utils' export function JSONUIRenderer({ @@ -154,7 +155,7 @@ export function JSONUIRenderer({ if (handler.condition) { const conditionMet = typeof handler.condition === 'function' ? handler.condition({ ...dataMap, ...renderContext }) - : evaluateCondition(handler.condition, { ...dataMap, ...renderContext }) + : evaluateConditionExpression(handler.condition, { ...dataMap, ...renderContext }, { label: 'event handler condition' }) if (!conditionMet) return } const eventPayload = typeof event === 'object' && event !== null @@ -196,7 +197,7 @@ export function JSONUIRenderer({ const renderWithContext = (renderContext: Record) => { if (component.conditional) { - const conditionMet = evaluateCondition(component.conditional.if, { ...dataMap, ...renderContext }) + const conditionMet = evaluateConditionExpression(component.conditional.if, { ...dataMap, ...renderContext }, { label: `component conditional (${component.id})` }) if (conditionMet) { if (component.conditional.then !== undefined) { return renderConditionalBranch( @@ -258,7 +259,7 @@ export function JSONUIRenderer({ let content = renderChildren(component.children, loopContext) if (component.conditional) { - const conditionMet = evaluateCondition(component.conditional.if, { ...dataMap, ...loopContext }) + const conditionMet = evaluateConditionExpression(component.conditional.if, { ...dataMap, ...loopContext }, { label: `loop conditional (${component.id})` }) if (conditionMet) { if (component.conditional.then !== undefined) { content = renderConditionalBranch( diff --git a/src/lib/json-ui/utils.ts b/src/lib/json-ui/utils.ts index 8113ce2..45a9099 100644 --- a/src/lib/json-ui/utils.ts +++ b/src/lib/json-ui/utils.ts @@ -1,3 +1,5 @@ +import { evaluateTransformExpression } from './expression-helpers' + type BindingTransform = string | ((data: unknown) => unknown) interface BindingSourceOptions { @@ -52,7 +54,7 @@ function applyTransform(value: unknown, transform?: BindingTransform) { return transform(value) } - return transformData(value, transform) + return evaluateTransformExpression(transform, value, {}, { label: 'data binding transform' }) } export function getNestedValue(obj: any, path: string): any { @@ -76,26 +78,6 @@ export function setNestedValue(obj: any, path: string, value: any): any { return obj } -export function evaluateCondition(condition: string, context: Record): boolean { - try { - const conditionFn = new Function(...Object.keys(context), `return ${condition}`) - return Boolean(conditionFn(...Object.values(context))) - } catch (err) { - console.warn('Failed to evaluate condition:', condition, err) - return false - } -} - -export function transformData(data: any, transformFn: string): any { - try { - const fn = new Function('data', `return ${transformFn}`) - return fn(data) - } catch (err) { - console.warn('Failed to transform data:', err) - return data - } -} - export function mergeClassNames(...classes: (string | undefined | null | false)[]): string { return classes.filter(Boolean).join(' ') }