function inside function

This commit is contained in:
2026-03-08 20:33:21 +01:00
parent e85750b39b
commit 0baad46ff0
3 changed files with 141 additions and 63 deletions

View File

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

View File

@@ -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) => (
<MenubarSub key={n.id}>
@@ -115,6 +128,22 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
</MenubarSubContent>
</MenubarSub>
))}
{connectedFunctionNodes.map((n: any) => (
<MenubarSub key={n.id}>
<MenubarSubTrigger className="text-xs flex items-center gap-2">
<Code2 className="size-3.5 shrink-0" />
{n.id}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem
className="text-xs"
onClick={() => insertFunctionAtCursor(n)}
>
Insert at cursor
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
))}
</>
) : undefined
}

View File

@@ -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<string>()
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<string, unknown> =>
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<string, string[]>
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<string, string[]>
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<string>(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<string, unknown>) : 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<string>(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<string, unknown>) : 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<string, string>()
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<string>((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<unknown>).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<string>; 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) => {