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