From cb895579989946619331bed91c219f0ad2da964e Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 7 Mar 2026 00:13:14 +0100 Subject: [PATCH] switch to plantuml --- package.json | 3 - src/App.tsx | 4 +- src/components/graph/ConfigNode.tsx | 18 +- src/components/graph/RenderingNode.tsx | 290 ++++++++++++++++--------- src/lib/plantumlLanguage.ts | 41 ++++ vite.config.ts | 10 + 6 files changed, 250 insertions(+), 116 deletions(-) create mode 100644 src/lib/plantumlLanguage.ts diff --git a/package.json b/package.json index bdb65d3..156eafd 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ }, "dependencies": { "@codemirror/lang-javascript": "^6.2.2", - "@codemirror/lang-yaml": "^6.0.2", "@uiw/react-codemirror": "^4.25.7", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-menubar": "^1.1.16", @@ -20,7 +19,6 @@ "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "js-yaml": "^4.1.0", "lucide-react": "^0.577.0", "react": "18.2.0", "react-dom": "18.2.0", @@ -29,7 +27,6 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "@types/js-yaml": "^4.0.9", "@types/node": "^25.3.3", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", diff --git a/src/App.tsx b/src/App.tsx index 3bf7777..2952814 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,7 +36,7 @@ const initialNodes: Node[] = [ { id: 'config-1', position: { x: 50, y: 50 }, - data: { yaml: "# example:\nmessage: Hello from config" }, + data: { plantuml: '@startuml\nactor User\nparticipant "Render" as R\nUser -> R : config\n@enduml\n' }, type: 'config', }, { @@ -116,7 +116,7 @@ export default function App() { function: 'function', } const dataMap: Record = { - config: { yaml: '# Enter YAML here\n', title: `config-${id}` }, + config: { plantuml: '@startuml\n\n@enduml\n', title: `config-${id}` }, render: {}, variable: { value: '', valueType: 'string' }, function: { body: '// args[0], args[1], ...\nreturn args[0];' }, diff --git a/src/components/graph/ConfigNode.tsx b/src/components/graph/ConfigNode.tsx index deff299..dc85eb0 100644 --- a/src/components/graph/ConfigNode.tsx +++ b/src/components/graph/ConfigNode.tsx @@ -1,7 +1,7 @@ import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import CodeMirror from '@uiw/react-codemirror' -import { yaml } from '@codemirror/lang-yaml' import FlowContext from '../../lib/flowContext' +import { plantumlLanguage } from '../../lib/plantumlLanguage' import { useTheme } from '../../lib/themeContext' import { BaseNode, @@ -29,11 +29,11 @@ type Props = { } export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { - const [value, setValue] = useState(data?.yaml ?? '# Enter YAML here\n') + const [value, setValue] = useState(data?.plantuml ?? '@startuml\n\n@enduml\n') const { theme } = useTheme() const ctx = useContext(FlowContext) const setNodes = ctx?.setNodes - const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? '' + const storedPlantuml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.plantuml ?? '' const editorRef = useRef(null) @@ -46,7 +46,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { useEffect(() => { if (setNodes) { - setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, yaml: value } } : n))) + setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, plantuml: value } } : n))) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -55,7 +55,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { (val: string) => { setValue(val) if (setNodes) { - setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, yaml: val } } : n))) + setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, plantuml: val } } : n))) } }, [id, setNodes] @@ -93,7 +93,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { const insertIncludeFromNode = useCallback( (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { - const ref = `${sourceNode.id}.yaml` + const ref = `${sourceNode.id}.puml` insertAt(`!include ${ref}\n`, mode) }, [insertAt] @@ -113,13 +113,13 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { [insertAt] ) - const extensions = useMemo(() => [yaml()], []) + const extensions = useMemo(() => [plantumlLanguage.extension], []) return ( - {id}.yml + {id}.puml @@ -210,7 +210,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { -
{storedYaml ? `${storedYaml.length} chars` : 'none'}
+
{storedPlantuml ? `${storedPlantuml.length} chars` : 'none'}
diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx index be9e33d..1f315e9 100644 --- a/src/components/graph/RenderingNode.tsx +++ b/src/components/graph/RenderingNode.tsx @@ -1,6 +1,6 @@ -import { memo, useContext, useEffect, useMemo, useState } from 'react' -import yaml from 'js-yaml' +import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react' import FlowContext from '../../lib/flowContext' +import { useTheme } from '../../lib/themeContext' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty' import { BaseNode, @@ -12,14 +12,63 @@ import { 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 } +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 }: Props) { - const [output, setOutput] = useState('') + 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 ?? [] @@ -31,14 +80,14 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { 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 yamlText = srcNode && srcNode.type === 'config' ? srcNode?.data?.yaml ?? '' : '' + 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?.yaml ?? ''}`) + .map((n: any) => `${n.id}:${n.data?.title ?? ''}:${n.data?.plantuml ?? ''}`) .join('|'), [nodes] ) @@ -71,118 +120,148 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { ) useEffect(() => { - // debug - // eslint-disable-next-line no-console - console.debug('RenderingNode:selection', { - id, - incomingIds, - srcNode: srcNode ? { id: srcNode.id, type: srcNode.type, yaml: (srcNode.data?.yaml ?? '').slice(0, 60) } : null, - }) - - if (!yamlText) { + if (!plantumlText) { if (incomingIds.length === 0) { - setOutput('') + setSvgContent(null) setError(null) + setLoading(false) return } - setOutput('') - setError({ kind: 'no-yaml', message: 'No YAML found on connected configuration node' }) + setSvgContent(null) + setError({ kind: 'no-plantuml', message: 'No PlantUML found on connected configuration node' }) + setLoading(false) return } - try { - const configIdsUsed = new Set() + runIdRef.current += 1 + const thisRunId = runIdRef.current + let cancelled = false - 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('.yaml') ? 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 run = async () => { + setLoading(true) + setError(null) + try { + const configIdsUsed = new Set() - 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) + 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 } - return false - } - if (!isReachable(refNode.id, id)) throw new Error(`Included node not connected to renderer: ${ref}`) + 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?.yaml ?? '') - const resolved = resolveIncludes(includedRaw, visited) - visited.delete(refNode.id) + 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 - }) - } + const indented = resolved + .split('\n') + .map((line: string) => (line === '' ? '' : indent + line)) + .join('\n') + return indented + }) + } - if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId) + if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId) - const resolvedYaml = resolveIncludes(yamlText) + const resolvedPlantuml = resolveIncludes(plantumlText) - const varMap: Record = {} - for (const e of edges) { - if (configIdsUsed.has(e.target)) { - const src = nodes.find((n: any) => n.id === e.source) - if (src?.type === 'variable') { - const v = src.data?.value - varMap[src.id] = v === undefined || v === null ? '' : String(v) + const varMap: Record = {} + for (const e of edges) { + if (configIdsUsed.has(e.target)) { + const src = nodes.find((n: any) => n.id === e.source) + if (src?.type === 'variable') { + const v = src.data?.value + varMap[src.id] = v === undefined || v === null ? '' : String(v) + } } } - } - 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 (err) { - return match - } + 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(resolvedPlantuml) + 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) } - - const resolveVariables = (text: string): string => - text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '') - - const afterFunctions = resolveFunctionCalls(resolvedYaml) - const resolvedWithVars = resolveVariables(afterFunctions) - const parsed = yaml.load(resolvedWithVars) - const newOutput = JSON.stringify(parsed, null, 2) - setError(null) - setOutput((prev) => (prev === newOutput ? prev : newOutput)) - } catch (err: any) { - const msg = 'YAML parse/include error: ' + err.message - setOutput('') - setError({ kind: 'parse', message: msg }) } - }, [id, incomingIds.join(','), yamlText, configSignature, edgesSignature, variablesSignature, functionsSignature]) + + 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]) return ( @@ -199,7 +278,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { No configuration connected - Connect a Configuration node or create one. The renderer will display parsed YAML. + Connect a Configuration node or create one. The renderer will display the PlantUML diagram.