diff --git a/src/components/graph/AnimatedEdge.tsx b/src/components/graph/AnimatedEdge.tsx
index de0ae5b..39b0ba6 100644
--- a/src/components/graph/AnimatedEdge.tsx
+++ b/src/components/graph/AnimatedEdge.tsx
@@ -11,7 +11,7 @@ const DOT_MARKER_R = 1.5
function getEdgeLabelByTargetType(targetType: string | undefined): string | undefined {
if (targetType === 'render') return 'render'
- if (targetType === 'config') return 'add input'
+ if (targetType === 'config' || targetType === 'function') return 'add input'
return undefined
}
@@ -64,7 +64,7 @@ export function AnimatedEdge({
r={DOT_MARKER_R}
cx={DOT_MARKER_R}
cy={DOT_MARKER_R}
- className="fill-primary stroke-background"
+ className="fill-primary"
strokeWidth={2}
/>
@@ -80,7 +80,7 @@ export function AnimatedEdge({
r={DOT_MARKER_R}
cx={DOT_MARKER_R}
cy={DOT_MARKER_R}
- className="fill-primary stroke-background"
+ className="fill-primary"
strokeWidth={2}
/>
diff --git a/src/components/graph/FunctionNode.tsx b/src/components/graph/FunctionNode.tsx
index 3157fd0..578538a 100644
--- a/src/components/graph/FunctionNode.tsx
+++ b/src/components/graph/FunctionNode.tsx
@@ -39,7 +39,13 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
[nodes, incomingIds]
)
+ const connectedFunctionNodes = useMemo(
+ () => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
+ [nodes, incomingIds]
+ )
const hasConnectedVariables = connectedVariableNodes.length > 0
+ const hasConnectedFunctions = connectedFunctionNodes.length > 0
+ const hasConnectedInputs = hasConnectedVariables || hasConnectedFunctions
const onChange = useCallback(
(val: string) => {
@@ -80,6 +86,13 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
[insertAt]
)
+ const insertFunctionAtCursor = useCallback(
+ (functionNode: any) => {
+ insertAt(functionNode.id, 'cursor')
+ },
+ [insertAt]
+ )
+
const extensions = useMemo(() => [javascript()], [])
const [editorHeight, editorContainerRef] = useResizeHeight(120)
const dimensions =
@@ -97,7 +110,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
nodeId={id}
nodeType="function"
editInputsContent={
- hasConnectedVariables ? (
+ hasConnectedInputs ? (
<>
{connectedVariableNodes.map((n: any) => (
@@ -115,6 +128,22 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
))}
+ {connectedFunctionNodes.map((n: any) => (
+
+
+
+ {n.id}
+
+
+ insertFunctionAtCursor(n)}
+ >
+ Insert at cursor
+
+
+
+ ))}
>
) : undefined
}
diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx
index b2b80c8..fc2cbce 100644
--- a/src/components/graph/RenderingNode.tsx
+++ b/src/components/graph/RenderingNode.tsx
@@ -247,8 +247,24 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
const src = nodes.find((n: any) => n.id === e.source)
if (src?.type === 'variable') setVarInContext(src)
}
+ // All function node ids that feed (directly or transitively) into config — need to register them and collect their variables
+ const functionIdsToRegister = new Set()
+ let added = true
+ while (added) {
+ added = false
+ for (const e of edges) {
+ const src = nodes.find((n: any) => n.id === e.source)
+ if (src?.type !== 'function') continue
+ const targetInScope = configIdsUsed.has(e.target) || functionIdsToRegister.has(e.target)
+ if (!targetInScope) continue
+ if (!functionIdsToRegister.has(src.id)) {
+ functionIdsToRegister.add(src.id)
+ added = true
+ }
+ }
+ }
for (const e of edges) {
- if (!configIdsUsed.has(e.target)) continue
+ if (!configIdsUsed.has(e.target) && !functionIdsToRegister.has(e.target)) continue
const src = nodes.find((n: any) => n.id === e.source)
if (src?.type === 'function') {
for (const e2 of edges) {
@@ -292,65 +308,90 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
const isPlainObject = (v: unknown): v is Record =>
typeof v === 'object' && v !== null && !Array.isArray(v)
- // For each function node that feeds a config: which variable node ids are connected to that function?
+ // For each registered function: which variable/function node ids are connected to it?
const functionConnectedVariableIds = Object.create(null) as Record
- for (const e of edges) {
- if (!configIdsUsed.has(e.target)) continue
- const src = nodes.find((n: any) => n.id === e.source)
- if (src?.type === 'function') {
- const fid = src.id
- for (const e2 of edges) {
- if (e2.target !== fid) continue
- const vNode = nodes.find((n: any) => n.id === e2.source)
- if (vNode?.type === 'variable') {
- if (!functionConnectedVariableIds[fid]) functionConnectedVariableIds[fid] = []
- functionConnectedVariableIds[fid].push(vNode.id)
- }
+ const functionConnectedFunctionIds = Object.create(null) as Record
+ for (const fid of functionIdsToRegister) {
+ for (const e of edges) {
+ if (e.target !== fid) continue
+ const src = nodes.find((n: any) => n.id === e.source)
+ if (src?.type === 'variable') {
+ if (!functionConnectedVariableIds[fid]) functionConnectedVariableIds[fid] = []
+ functionConnectedVariableIds[fid].push(src.id)
+ } else if (src?.type === 'function') {
+ if (!functionConnectedFunctionIds[fid]) functionConnectedFunctionIds[fid] = []
+ functionConnectedFunctionIds[fid].push(src.id)
}
}
}
- for (const e of edges) {
- if (!configIdsUsed.has(e.target)) continue
- const src = nodes.find((n: any) => n.id === e.source)
- if (src?.type === 'function') {
- const body = src.data?.body ?? 'return args[0];'
- const parsed = parseFunctionSignature(body)
- const connectedVarIds = new Set(functionConnectedVariableIds[src.id] ?? [])
- env.addFilter(
- src.id,
- (value: unknown, ...args: unknown[]) => {
- const callback = args[args.length - 1] as (err: Error | null, res: string) => void
- const raw = [value, ...args.slice(0, -1)]
- const hasKwargs = raw.length > 0 && isPlainObject(raw[raw.length - 1])
- const positionals = hasKwargs ? raw.slice(0, -1) : raw
- const kwargs = hasKwargs ? (raw[raw.length - 1] as Record) : Object.create(null)
+ for (const fid of functionIdsToRegister) {
+ const src = nodes.find((n: any) => n.id === fid)
+ if (!src || src.type !== 'function') continue
+ const body = src.data?.body ?? 'return args[0];'
+ const parsed = parseFunctionSignature(body)
+ const connectedVarIds = new Set(functionConnectedVariableIds[fid] ?? [])
+ const connectedFuncIds = functionConnectedFunctionIds[fid] ?? []
+ env.addFilter(
+ src.id,
+ (value: unknown, ...args: unknown[]) => {
+ const callback = args[args.length - 1] as (err: Error | null, res: string) => void
+ const raw = [value, ...args.slice(0, -1)]
+ const hasKwargs = raw.length > 0 && isPlainObject(raw[raw.length - 1])
+ const positionals = hasKwargs ? raw.slice(0, -1) : raw
+ const kwargs = hasKwargs ? (raw[raw.length - 1] as Record) : Object.create(null)
- let invoke: () => unknown
- if (parsed) {
- const { paramNames, innerBody } = parsed
- const lastParam = paramNames[paramNames.length - 1]
- const invocationArgs = paramNames.map((name, i) => {
- if (name === lastParam && lastParam === 'kwargs') return kwargs
- // Connected variables are available as constants: use variable value from context
- if (connectedVarIds.has(name) && name in nunjucksContext)
- return nunjucksContext[name]
- return positionals[i]
- })
- // Inject connected variables as extra params so they're in scope in the body (e.g. (value) => value + var_001)
- const extraVarIds = [...connectedVarIds].filter((vid) => !paramNames.includes(vid))
- const allParamNames = [...paramNames, ...extraVarIds]
- const allArgs = [...invocationArgs, ...extraVarIds.map((vid) => nunjucksContext[vid])]
- const fn = new Function(...allParamNames, innerBody)
- invoke = () => fn(...allArgs)
- } else {
- const fn = new Function('args', body)
- invoke = () => fn(positionals)
- }
+ // Cache for nested filter results so sync-looking code like `num + fn_003(4)` works:
+ // callable throws Suspend when result isn't ready; we await, cache, then re-run.
+ // Cached values are strings (Nunjucks); coerce to number when numeric so 2 + fn_003(4) => 10 not "28".
+ const nestedCache = new Map()
+ const coerceCached = (s: string): string | number => {
+ const n = Number(s)
+ return s.trim() !== '' && !Number.isNaN(n) ? n : s
+ }
+ const makeCallable = (filterId: string) => (input: unknown) => {
+ const key = `${filterId}::${JSON.stringify(input)}`
+ if (nestedCache.has(key)) return coerceCached(nestedCache.get(key)!)
+ const p = new Promise((resolve, reject) => {
+ env.getFilter(filterId)(input, (err: Error | null, res: string) =>
+ err ? reject(err) : resolve(res)
+ )
+ })
+ p.then((res) => nestedCache.set(key, res))
+ const suspend = { __suspend: true as const, promise: p, key }
+ throw suspend
+ }
- const done = (err: Error | null, res: string) => {
- callback(err, res)
- }
+ let invoke: () => unknown
+ if (parsed) {
+ const { paramNames, innerBody } = parsed
+ const lastParam = paramNames[paramNames.length - 1]
+ const invocationArgs = paramNames.map((name, i) => {
+ if (name === lastParam && lastParam === 'kwargs') return kwargs
+ if (connectedVarIds.has(name) && name in nunjucksContext)
+ return nunjucksContext[name]
+ if (connectedFuncIds.includes(name)) return makeCallable(name)
+ return positionals[i]
+ })
+ const extraVarIds = [...connectedVarIds].filter((vid) => !paramNames.includes(vid))
+ const extraFuncIds = connectedFuncIds.filter((fid2) => !paramNames.includes(fid2))
+ const allParamNames = [...paramNames, ...extraVarIds, ...extraFuncIds]
+ const allArgs = [
+ ...invocationArgs,
+ ...extraVarIds.map((vid) => nunjucksContext[vid]),
+ ...extraFuncIds.map((fid2) => makeCallable(fid2)),
+ ]
+ const fn = new Function(...allParamNames, innerBody)
+ invoke = () => fn(...allArgs)
+ } else {
+ const fn = new Function('args', body)
+ invoke = () => fn(positionals)
+ }
+
+ const done = (err: Error | null, res: string) => {
+ callback(err, res)
+ }
+ const runInvoke = () => {
try {
const result = invoke()
if (result != null && typeof (result as Promise).then === 'function') {
@@ -361,13 +402,21 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
} else {
done(null, formatFilterResult(result))
}
- } catch (err) {
- done(err instanceof Error ? err : new Error(String(err)), '')
+ } catch (e: unknown) {
+ const s = e as { __suspend?: boolean; promise?: Promise; key?: string }
+ if (s?.__suspend && s.promise) {
+ s.promise.then(() => runInvoke(), (err) =>
+ done(err instanceof Error ? err : new Error(String(err)), '')
+ )
+ } else {
+ done(e instanceof Error ? e : new Error(String(e)), '')
+ }
}
- },
- true
- )
- }
+ }
+ runInvoke()
+ },
+ true
+ )
}
env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => {