From 0baad46ff032036a41dc67fc155320a91794586a Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 8 Mar 2026 20:33:21 +0100 Subject: [PATCH] function inside function --- src/components/graph/AnimatedEdge.tsx | 6 +- src/components/graph/FunctionNode.tsx | 31 ++++- src/components/graph/RenderingNode.tsx | 167 ++++++++++++++++--------- 3 files changed, 141 insertions(+), 63 deletions(-) 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) => {