diff --git a/src/components/graph/ConfigNode.tsx b/src/components/graph/ConfigNode.tsx
index 321473c..c5c99ea 100644
--- a/src/components/graph/ConfigNode.tsx
+++ b/src/components/graph/ConfigNode.tsx
@@ -117,7 +117,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
const insertFunctionCall = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
- insertAt(`{{ ${sourceNode.id}() }}`, mode)
+ insertAt(`{{ '' | ${sourceNode.id} }}`, mode)
},
[insertAt]
)
diff --git a/src/components/graph/FunctionNode.tsx b/src/components/graph/FunctionNode.tsx
index f3f57b7..13a3210 100644
--- a/src/components/graph/FunctionNode.tsx
+++ b/src/components/graph/FunctionNode.tsx
@@ -72,6 +72,9 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
{bodyValue ? `${bodyValue.length} chars` : 'none'}
+
+ In config: {'{{ x | '}{id}{' }}'} or {'{{ x | '}{id}{'(a, b, key=val) }}'}. Use function(num, x, y, kwargs) { ... } — kwargs has keyword args.
+
)
diff --git a/src/components/graph/NodeStatusIndicator.tsx b/src/components/graph/NodeStatusIndicator.tsx
index 74c1d12..662621a 100644
--- a/src/components/graph/NodeStatusIndicator.tsx
+++ b/src/components/graph/NodeStatusIndicator.tsx
@@ -1,6 +1,5 @@
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
-import { flushSync } from 'react-dom'
import { cn } from '@/lib/utils'
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
@@ -68,7 +67,7 @@ function BorderLoadingIndicator({
const cw = (target as HTMLElement).offsetWidth
const ch = (target as HTMLElement).offsetHeight
if (cw > 0 && ch > 0) {
- flushSync(() => setMeasured({ w: cw, h: ch }))
+ queueMicrotask(() => setMeasured({ w: cw, h: ch }))
}
}
syncMeasure()
diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx
index 5484caa..b1be1c7 100644
--- a/src/components/graph/RenderingNode.tsx
+++ b/src/components/graph/RenderingNode.tsx
@@ -234,9 +234,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
},
}
- // Use null prototype so keys like "var", "constructor", "toString" don't collide with Object.prototype.
+ // Context: only variables. Function nodes are registered as Nunjucks custom filters (see below).
const nunjucksContext = 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)
@@ -245,67 +244,149 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
const str = v === undefined || v === null ? '' : String(v)
nunjucksContext[src.id] =
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
- } else if (src?.type === 'function') {
+ }
+ }
+
+ const env = new nunjucks.Environment([configLoader], { autoescape: false })
+
+ // Register each connected function node as a Nunjucks custom filter (async so sync and async user code both work).
+ // Supports either named params: function(num, x, y, kwargs) { return num + (kwargs.bar || 10); } or legacy: args array.
+ const formatFilterResult = (r: unknown): string => {
+ if (r === undefined || r === null) return ''
+ if (typeof r === 'string' || typeof r === 'number' || typeof r === 'boolean') return String(r)
+ return String(r)
+ }
+ /** Parse function(num, x, y, kwargs) { body } or (num, x, y, kwargs) => body to get param names and inner body. */
+ const parseFunctionSignature = (body: string): { paramNames: string[]; innerBody: string } | null => {
+ const withCommentsStripped = body.replace(/^\s*\/\/[^\n]*\n?/gm, '').trim()
+ const trimmed = withCommentsStripped.trim()
+ const fnMatch = trimmed.match(/^function\s*\(([^)]*)\)\s*\{([\s\S]*)\}\s*$/)
+ if (fnMatch) {
+ const paramNames = fnMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
+ return { paramNames, innerBody: fnMatch[2].trim() }
+ }
+ const arrowBlockMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*\{([\s\S]*)\}\s*$/)
+ if (arrowBlockMatch) {
+ const paramNames = arrowBlockMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
+ return { paramNames, innerBody: arrowBlockMatch[2].trim() }
+ }
+ const arrowExprMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*(.+)\s*$/s)
+ if (arrowExprMatch) {
+ const paramNames = arrowExprMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
+ return { paramNames, innerBody: 'return ' + arrowExprMatch[2].trim() }
+ }
+ return null
+ }
+ const isPlainObject = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v)
+
+ 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];'
- nunjucksContext[src.id] = (...args: unknown[]) => {
- try {
- const fn = new Function('args', body)
- const result = fn(args)
- if (result === undefined || result === null) return ''
- if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return result
- return String(result)
- } catch {
- return ''
+ const parsed = parseFunctionSignature(body)
+ 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
+ return positionals[i]
+ })
+ const fn = new Function(...paramNames, innerBody)
+ invoke = () => fn(...invocationArgs)
+ } else {
+ const fn = new Function('args', body)
+ invoke = () => fn(positionals)
+ }
+
+ const done = (err: Error | null, res: string) => {
+ callback(err, res)
+ }
+ try {
+ const result = invoke()
+ if (result != null && typeof (result as Promise).then === 'function') {
+ (result as Promise).then(
+ (r) => done(null, formatFilterResult(r)),
+ (err) => done(err instanceof Error ? err : new Error(String(err)), '')
+ )
+ } else {
+ done(null, formatFilterResult(result))
+ }
+ } catch (err) {
+ done(err instanceof Error ? err : new Error(String(err)), '')
+ }
+ },
+ true
+ )
+ }
+ }
+
+ env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => {
+ if (cancelled || thisRunId !== runIdRef.current) return
+ if (nunjucksErr) {
+ setSvgContent(null)
+ setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
+ setLoading(false)
+ return
+ }
+
+ const resolvedPlantuml = afterNunjucks
+
+ try {
+ const res = await fetch(KROKI_PLANTUML_SVG, {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/plain' },
+ body: resolvedPlantuml,
+ })
+
+ if (cancelled || thisRunId !== runIdRef.current) return
+
+ if (!res.ok) {
+ const errText = await res.text()
+ throw new Error(res.status === 400 ? errText || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
+ }
+
+ const svg = await res.text()
+ if (thisRunId !== runIdRef.current) return
+ setSvgContent(svg)
+ setError(null)
+ } catch (err: any) {
+ if (cancelled || thisRunId !== runIdRef.current) return
+ const msg = err?.message ?? 'PlantUML render error'
+ setSvgContent(null)
+ setError({ kind: 'render', message: msg })
+ } finally {
+ if (!cancelled && thisRunId === runIdRef.current) {
+ const startedAt = loadingStartedAtRef.current ?? 0
+ const elapsed = Date.now() - startedAt
+ const remaining = Math.max(0, 1000 - elapsed)
+ if (remaining > 0) {
+ minLoadingTimeoutRef.current = setTimeout(() => {
+ minLoadingTimeoutRef.current = null
+ if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
+ }, remaining)
+ } else {
+ setLoading(false)
}
}
}
- }
-
- let afterNunjucks: string
- try {
- const env = new nunjucks.Environment([configLoader], { autoescape: false })
- afterNunjucks = env.render(srcId!, nunjucksContext)
- } catch (nunjucksErr: any) {
- throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`)
- }
-
- const resolvedPlantuml = afterNunjucks
-
- const res = await fetch(KROKI_PLANTUML_SVG, {
- method: 'POST',
- headers: { 'Content-Type': 'text/plain' },
- body: resolvedPlantuml,
})
-
- if (cancelled || thisRunId !== runIdRef.current) return
-
- if (!res.ok) {
- const errText = await res.text()
- throw new Error(res.status === 400 ? errText || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
- }
-
- const svg = await res.text()
- if (thisRunId !== runIdRef.current) return
- setSvgContent(svg)
- setError(null)
} catch (err: any) {
- if (cancelled || thisRunId !== runIdRef.current) return
- const msg = err?.message ?? 'PlantUML render error'
- setSvgContent(null)
- setError({ kind: 'render', message: msg })
- } finally {
if (!cancelled && thisRunId === runIdRef.current) {
- const startedAt = loadingStartedAtRef.current ?? 0
- const elapsed = Date.now() - startedAt
- const remaining = Math.max(0, 1000 - elapsed)
- if (remaining > 0) {
- minLoadingTimeoutRef.current = setTimeout(() => {
- minLoadingTimeoutRef.current = null
- if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
- }, remaining)
- } else {
- setLoading(false)
- }
+ setSvgContent(null)
+ setError({ kind: 'render', message: err?.message ?? 'PlantUML render error' })
+ setLoading(false)
}
}
}
diff --git a/src/lib/flowUtils.ts b/src/lib/flowUtils.ts
index f805b66..5c1b4c3 100644
--- a/src/lib/flowUtils.ts
+++ b/src/lib/flowUtils.ts
@@ -57,7 +57,11 @@ const DEFAULT_DATA: Record = {
config: { plantuml: '@startuml\n\n@enduml\n', title: '' },
render: {},
variable: { value: '', valueType: 'string' },
- function: { body: '// args[0], args[1], ...\nreturn args[0];' },
+ function: {
+ body: `function(num, kwargs) {
+ return num + (kwargs.bar || 0);
+}`,
+ },
}
export function getDefaultDataForType(type: string, newId?: string): any {
diff --git a/src/lib/nunjucksAutocomplete.ts b/src/lib/nunjucksAutocomplete.ts
index 2d13581..dfffa1d 100644
--- a/src/lib/nunjucksAutocomplete.ts
+++ b/src/lib/nunjucksAutocomplete.ts
@@ -61,7 +61,7 @@ export function nunjucksCompletionSource(
}
for (const id of functionIds ?? []) {
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
- options.push({ label: id, type: 'function', info: 'Function (e.g. ' + id + '(var, 4))' })
+ options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" })
}
}
for (const kw of NUNJUCKS_KEYWORDS) {