Replace eval bindings with JSON expression evaluator

This commit is contained in:
2026-01-18 17:09:02 +00:00
parent 352ceba09f
commit c6208fafd1
8 changed files with 159 additions and 62 deletions

View File

@@ -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<string, any>): 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) {

View File

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

View File

@@ -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<string, any>): 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',
})
}
}, [])

View File

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

View File

@@ -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<string, any>)
: evaluateCondition(handler.condition, mergedData as Record<string, any>))
: evaluateConditionExpression(handler.condition, mergedData as Record<string, any>, { label: 'event handler condition' }))
if (conditionMet) {
const eventPayload = typeof e === 'object' && e !== null
? Object.assign(e as Record<string, unknown>, context)
@@ -195,7 +196,7 @@ export function ComponentRenderer({ component, data, context = {}, state, onEven
const renderConditionalContent = (renderContext: Record<string, unknown>) => {
if (!component.conditional) return undefined
const conditionMet = evaluateCondition(component.conditional.if, { ...data, ...renderContext } as Record<string, any>)
const conditionMet = evaluateConditionExpression(component.conditional.if, { ...data, ...renderContext } as Record<string, any>, { label: `component conditional (${component.id})` })
if (conditionMet) {
if (component.conditional.then !== undefined) {
return renderBranch(component.conditional.then as UIComponent | (UIComponent | string)[] | string, renderContext)

View File

@@ -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<string, any>,
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<string, any> = {},
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<string, any>,
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 })
}

View File

@@ -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<string, unknown>) => {
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(

View File

@@ -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<string, any>): 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(' ')
}