import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react' import nunjucks from 'nunjucks' import FlowContext from '../../lib/flowContext' import { useTheme } from '../../lib/themeContext' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeFooterText, BaseNodeHeaderRow, } from './BaseNode' import { Sparkles } from 'lucide-react' import { InputHandle, OutputHandle } from './NodeHandles' // Use relative URL so Vite dev proxy (and optional prod proxy) avoids CORS const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg' type Props = { id: string data?: any style?: React.CSSProperties } const DEFAULT_CONFIG_NODE_STYLE = { width: 320, height: 320 } const DARK_SKINPARAMS = ` skinparam backgroundColor #1e1e1e skinparam defaultFontColor #e0e0e0 skinparam shadowing false skinparam ArrowColor #b0b0b0 skinparam ArrowFontColor #e0e0e0 skinparam ActivityBackgroundColor #2d2d2d skinparam ActivityBorderColor #6b6b6b skinparam ActivityDiamondBackgroundColor #2d2d2d skinparam ActivityDiamondBorderColor #6b6b6b skinparam SequenceParticipantBackgroundColor #2d2d2d skinparam SequenceParticipantBorderColor #6b6b6b skinparam SequenceLifeLineBorderColor #6b6b6b skinparam SequenceBoxBackgroundColor #252525 skinparam SequenceBoxBorderColor #6b6b6b skinparam SequenceActorBackgroundColor #2d2d2d skinparam SequenceActorBorderColor #6b6b6b skinparam ClassBackgroundColor #2d2d2d skinparam ClassBorderColor #6b6b6b skinparam ComponentBackgroundColor #2d2d2d skinparam ComponentBorderColor #6b6b6b skinparam StateBackgroundColor #2d2d2d skinparam StateBorderColor #6b6b6b skinparam partitionBorderColor #6b6b6b skinparam sequence { ArrowColor #b0b0b0 LifeLineBorderColor #6b6b6b LifeLineBackgroundColor #2d2d2d ParticipantBorderColor #6b6b6b ActorBorderColor #6b6b6b BoxBorderColor #6b6b6b } skinparam activity { ArrowColor #b0b0b0 BorderColor #6b6b6b DiamondBorderColor #6b6b6b } skinparam class { ArrowColor #b0b0b0 BorderColor #6b6b6b } ` export const RenderingNode = memo(function RenderingNode({ id, width, height }: Props) { const [svgContent, setSvgContent] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const runIdRef = useRef(0) const { theme } = useTheme() const ctx = useContext(FlowContext) const nodes = ctx?.nodes ?? [] const edges = ctx?.edges ?? [] const setNodes = ctx?.setNodes const setEdges = ctx?.setEdges const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id]) const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges]) const srcId = incomingIds.length > 0 ? incomingIds[0] : null const srcNode = nodes.find((n: any) => n.id === srcId) const plantumlText = srcNode && srcNode.type === 'config' ? srcNode?.data?.plantuml ?? '' : '' const srcData = srcNode?.data ?? {} const configSignature = useMemo( () => nodes .filter((n: any) => n.type === 'config') .map((n: any) => `${n.id}:${n.data?.title ?? ''}:${n.data?.plantuml ?? ''}`) .join('|'), [nodes] ) const edgesSignature = useMemo( () => edges .map((e: any) => `${e.source}->${e.target}`) .sort() .join('|'), [edges] ) const variablesSignature = useMemo( () => nodes .filter((n: any) => n.type === 'variable') .map((n: any) => `${n.id}:${n.data?.value}`) .join('|'), [nodes] ) const functionsSignature = useMemo( () => nodes .filter((n: any) => n.type === 'function') .map((n: any) => `${n.id}:${n.data?.body ?? ''}`) .join('|'), [nodes] ) useEffect(() => { if (!plantumlText) { if (incomingIds.length === 0) { setSvgContent(null) setError(null) setLoading(false) return } setSvgContent(null) setError({ kind: 'no-plantuml', message: 'No PlantUML found on connected configuration node' }) setLoading(false) return } runIdRef.current += 1 const thisRunId = runIdRef.current let cancelled = false const run = async () => { setLoading(true) setError(null) try { const configIdsUsed = new Set() const resolveIncludes = (text: string, visited = new Set()): string => { const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm return text.replace(includeRegex, (match: string, indent: string, ref: string) => { const refName = ref.endsWith('.puml') ? ref.slice(0, -5) : ref const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName) if (!refNode) throw new Error(`Included node not found: ${ref}`) if (visited.has(refNode.id)) throw new Error(`Circular include detected: ${ref}`) const isReachable = (startId: string, targetId: string) => { const q: string[] = [startId] const seen = new Set([startId]) while (q.length) { const cur = q.shift()! if (cur === targetId) return true for (const e of edges) { if (e.source === cur && !seen.has(e.target)) { seen.add(e.target) q.push(e.target) } } } return false } if (!isReachable(refNode.id, id)) throw new Error(`Included node not connected to renderer: ${ref}`) visited.add(refNode.id) if (refNode.type === 'config') configIdsUsed.add(refNode.id) const includedRaw = String(refNode.data?.plantuml ?? '') const resolved = resolveIncludes(includedRaw, visited) visited.delete(refNode.id) const indented = resolved .split('\n') .map((line: string) => (line === '' ? '' : indent + line)) .join('\n') return indented }) } if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId) const resolvedIncludes = resolveIncludes(plantumlText) const varMap: Record = {} // Use null prototype so keys like "var", "constructor", "toString" don't collide with Object.prototype 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) if (src?.type === 'variable') { const v = src.data?.value const str = v === undefined || v === null ? '' : String(v) varMap[src.id] = str // Keep booleans/numbers for {% if %} etc.; Nunjucks treats "false" as truthy nunjucksContext[src.id] = v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str } else 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 '' } } } } let afterNunjucks: string try { const env = new nunjucks.Environment([], { autoescape: false }) afterNunjucks = env.renderString(resolvedIncludes, nunjucksContext) } catch (nunjucksErr: any) { throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`) } const resolveFunctionCalls = (text: string): string => { const fnCallRegex = /\$\{([\w-]+)\s*\(([^)]*)\)\}/g return text.replace(fnCallRegex, (match, funcId: string, argsStr: string) => { const fnNode = nodes.find((n: any) => n.id === funcId && n.type === 'function') if (!fnNode) return match const body = fnNode.data?.body ?? 'return args[0];' const argIds = argsStr.split(',').map((s: string) => s.trim()).filter(Boolean) const argValues = argIds.map((argId: string) => varMap[argId] ?? '') try { const fn = new Function('args', body) const result = fn(argValues) if (result === undefined || result === null) return '' if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return String(result) return String(result) } catch { return match } }) } const resolveVariables = (text: string): string => text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '') const afterFunctions = resolveFunctionCalls(afterNunjucks) let resolvedWithVars = resolveVariables(afterFunctions) if (theme === 'dark') { resolvedWithVars = resolvedWithVars.replace( /^(\s*@startuml\s*\n)/i, `$1${DARK_SKINPARAMS}\n` ) } const res = await fetch(KROKI_PLANTUML_SVG, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: resolvedWithVars, }) 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) setLoading(false) } } run() return () => { cancelled = true } // Only re-run when inputs that affect the resolved diagram change (signatures + source + theme). // Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders. }, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature]) const dimensions = width != null && height != null && width > 0 && height > 0 ? { width, height } : undefined return ( }> } title={id} />
{incomingIds.length === 0 ? ( No configuration connected Connect a Configuration node or create one. The renderer will display the PlantUML diagram. ) : error ? ( srcData?.renderError ? ( srcData.renderError(error) ) : srcData?.errorHtml ? (
) : (
{error.message}
) ) : loading ? (
Rendering…
) : svgContent ? (
) : null}
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'} ) }) RenderingNode.displayName = 'RenderingNode' export default RenderingNode