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) {