switch to plantuml

This commit is contained in:
2026-03-07 00:13:14 +01:00
parent b7aa1079d4
commit cb89557998
6 changed files with 250 additions and 116 deletions

View File

@@ -9,7 +9,6 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-yaml": "^6.0.2",
"@uiw/react-codemirror": "^4.25.7", "@uiw/react-codemirror": "^4.25.7",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-menubar": "^1.1.16",
@@ -20,7 +19,6 @@
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-yaml": "^4.1.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
@@ -29,7 +27,6 @@
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/react": "^18.0.0", "@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0", "@types/react-dom": "^18.0.0",

View File

@@ -36,7 +36,7 @@ const initialNodes: Node[] = [
{ {
id: 'config-1', id: 'config-1',
position: { x: 50, y: 50 }, 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', type: 'config',
}, },
{ {
@@ -116,7 +116,7 @@ export default function App() {
function: 'function', function: 'function',
} }
const dataMap: Record<string, any> = { const dataMap: Record<string, any> = {
config: { yaml: '# Enter YAML here\n', title: `config-${id}` }, config: { plantuml: '@startuml\n\n@enduml\n', title: `config-${id}` },
render: {}, render: {},
variable: { value: '', valueType: 'string' }, variable: { value: '', valueType: 'string' },
function: { body: '// args[0], args[1], ...\nreturn args[0];' }, function: { body: '// args[0], args[1], ...\nreturn args[0];' },

View File

@@ -1,7 +1,7 @@
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { yaml } from '@codemirror/lang-yaml'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { plantumlLanguage } from '../../lib/plantumlLanguage'
import { useTheme } from '../../lib/themeContext' import { useTheme } from '../../lib/themeContext'
import { import {
BaseNode, BaseNode,
@@ -29,11 +29,11 @@ type Props = {
} }
export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
const [value, setValue] = useState<string>(data?.yaml ?? '# Enter YAML here\n') const [value, setValue] = useState<string>(data?.plantuml ?? '@startuml\n\n@enduml\n')
const { theme } = useTheme() const { theme } = useTheme()
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes 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<unknown>(null) const editorRef = useRef<unknown>(null)
@@ -46,7 +46,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
useEffect(() => { useEffect(() => {
if (setNodes) { 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@@ -55,7 +55,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
(val: string) => { (val: string) => {
setValue(val) setValue(val)
if (setNodes) { 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] [id, setNodes]
@@ -93,7 +93,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
const insertIncludeFromNode = useCallback( const insertIncludeFromNode = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
const ref = `${sourceNode.id}.yaml` const ref = `${sourceNode.id}.puml`
insertAt(`!include ${ref}\n`, mode) insertAt(`!include ${ref}\n`, mode)
}, },
[insertAt] [insertAt]
@@ -113,13 +113,13 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
[insertAt] [insertAt]
) )
const extensions = useMemo(() => [yaml()], []) const extensions = useMemo(() => [plantumlLanguage.extension], [])
return ( return (
<BaseNode className="w-80"> <BaseNode className="w-80">
<BaseNodeHeader className="border-b"> <BaseNodeHeader className="border-b">
<ScrollText className="size-4" /> <ScrollText className="size-4" />
<BaseNodeHeaderTitle>{id}.yml</BaseNodeHeaderTitle> <BaseNodeHeaderTitle>{id}.puml</BaseNodeHeaderTitle>
</BaseNodeHeader> </BaseNodeHeader>
<BaseNodeContent> <BaseNodeContent>
@@ -210,7 +210,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<div className="w-full text-xs text-muted-foreground">{storedYaml ? `${storedYaml.length} chars` : 'none'}</div> <div className="w-full text-xs text-muted-foreground">{storedPlantuml ? `${storedPlantuml.length} chars` : 'none'}</div>
</BaseNodeFooter> </BaseNodeFooter>
<InputHandle id="ain" /> <InputHandle id="ain" />

View File

@@ -1,6 +1,6 @@
import { memo, useContext, useEffect, useMemo, useState } from 'react' import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react'
import yaml from 'js-yaml'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { useTheme } from '../../lib/themeContext'
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
import { import {
BaseNode, BaseNode,
@@ -12,14 +12,63 @@ import {
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { InputHandle, OutputHandle } from './NodeHandles' 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 = { type Props = {
id: string id: string
data?: any 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) { export const RenderingNode = memo(function RenderingNode({ id }: Props) {
const [output, setOutput] = useState<string>('') const [svgContent, setSvgContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null) const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false)
const runIdRef = useRef(0)
const { theme } = useTheme()
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? [] 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 incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
const srcId = incomingIds.length > 0 ? incomingIds[0] : null const srcId = incomingIds.length > 0 ? incomingIds[0] : null
const srcNode = nodes.find((n: any) => n.id === srcId) 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 srcData = srcNode?.data ?? {}
const configSignature = useMemo( const configSignature = useMemo(
() => () =>
nodes nodes
.filter((n: any) => n.type === 'config') .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('|'), .join('|'),
[nodes] [nodes]
) )
@@ -71,32 +120,33 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
) )
useEffect(() => { useEffect(() => {
// debug if (!plantumlText) {
// 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 (incomingIds.length === 0) { if (incomingIds.length === 0) {
setOutput('') setSvgContent(null)
setError(null) setError(null)
setLoading(false)
return return
} }
setOutput('') setSvgContent(null)
setError({ kind: 'no-yaml', message: 'No YAML found on connected configuration node' }) setError({ kind: 'no-plantuml', message: 'No PlantUML found on connected configuration node' })
setLoading(false)
return return
} }
runIdRef.current += 1
const thisRunId = runIdRef.current
let cancelled = false
const run = async () => {
setLoading(true)
setError(null)
try { try {
const configIdsUsed = new Set<string>() const configIdsUsed = new Set<string>()
const resolveIncludes = (text: string, visited = new Set<string>()): string => { const resolveIncludes = (text: string, visited = new Set<string>()): string => {
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
return text.replace(includeRegex, (match: string, indent: string, ref: string) => { return text.replace(includeRegex, (match: string, indent: string, ref: string) => {
const refName = ref.endsWith('.yaml') ? ref.slice(0, -5) : ref const refName = ref.endsWith('.puml') ? ref.slice(0, -5) : ref
const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName) const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName)
if (!refNode) throw new Error(`Included node not found: ${ref}`) if (!refNode) throw new Error(`Included node not found: ${ref}`)
if (visited.has(refNode.id)) throw new Error(`Circular include detected: ${ref}`) if (visited.has(refNode.id)) throw new Error(`Circular include detected: ${ref}`)
@@ -121,7 +171,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
visited.add(refNode.id) visited.add(refNode.id)
if (refNode.type === 'config') configIdsUsed.add(refNode.id) if (refNode.type === 'config') configIdsUsed.add(refNode.id)
const includedRaw = String(refNode.data?.yaml ?? '') const includedRaw = String(refNode.data?.plantuml ?? '')
const resolved = resolveIncludes(includedRaw, visited) const resolved = resolveIncludes(includedRaw, visited)
visited.delete(refNode.id) visited.delete(refNode.id)
@@ -135,7 +185,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
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<string, string> = {} const varMap: Record<string, string> = {}
for (const e of edges) { for (const e of edges) {
@@ -162,7 +212,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
if (result === undefined || result === null) return '' if (result === undefined || result === null) return ''
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return String(result) if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return String(result)
return String(result) return String(result)
} catch (err) { } catch {
return match return match
} }
}) })
@@ -171,18 +221,47 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
const resolveVariables = (text: string): string => const resolveVariables = (text: string): string =>
text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '') text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '')
const afterFunctions = resolveFunctionCalls(resolvedYaml) const afterFunctions = resolveFunctionCalls(resolvedPlantuml)
const resolvedWithVars = resolveVariables(afterFunctions) let resolvedWithVars = resolveVariables(afterFunctions)
const parsed = yaml.load(resolvedWithVars) if (theme === 'dark') {
const newOutput = JSON.stringify(parsed, null, 2) resolvedWithVars = resolvedWithVars.replace(
setError(null) /^(\s*@startuml\s*\n)/i,
setOutput((prev) => (prev === newOutput ? prev : newOutput)) `$1${DARK_SKINPARAMS}\n`
} 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])
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])
return ( return (
<BaseNode className="w-96"> <BaseNode className="w-96">
@@ -199,7 +278,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
<Sparkles className="size-6" /> <Sparkles className="size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>No configuration connected</EmptyTitle> <EmptyTitle>No configuration connected</EmptyTitle>
<EmptyDescription>Connect a Configuration node or create one. The renderer will display parsed YAML.</EmptyDescription> <EmptyDescription>Connect a Configuration node or create one. The renderer will display the PlantUML diagram.</EmptyDescription>
</EmptyHeader> </EmptyHeader>
<EmptyContent> <EmptyContent>
<button <button
@@ -211,7 +290,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
const thisNode = nodes.find((n: any) => n.id === id) const thisNode = nodes.find((n: any) => n.id === id)
const pos = thisNode?.position ?? { x: 0, y: 0 } const pos = thisNode?.position ?? { x: 0, y: 0 }
const newPos = { x: pos.x - 220, y: pos.y } const newPos = { x: pos.x - 220, y: pos.y }
const newNode = { id: nid, type: 'config', position: newPos, data: { yaml: '# Enter YAML\n', title: `config-${nid}` } } const newNode = { id: nid, type: 'config', position: newPos, data: { plantuml: '@startuml\n\n@enduml\n', title: `config-${nid}` } }
setNodes((nds: any[]) => nds.concat(newNode)) setNodes((nds: any[]) => nds.concat(newNode))
const edgeId = `e-${nid}-${id}` const edgeId = `e-${nid}-${id}`
setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id })) setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id }))
@@ -225,17 +304,24 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
srcData?.renderError ? ( srcData?.renderError ? (
srcData.renderError(error) srcData.renderError(error)
) : srcData?.errorHtml ? ( ) : srcData?.errorHtml ? (
<div className="p-3 text-xs text-red-700" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} /> <div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
) : ( ) : (
<div className="p-3 text-xs text-red-700">{error.message}</div> <div className="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div>
) )
) : ( ) : loading ? (
<pre className="text-xs text-foreground max-h-48 overflow-auto">{output}</pre> <div className="flex min-h-[120px] items-center justify-center text-xs text-muted-foreground">Rendering</div>
)} ) : svgContent ? (
<div
className="min-h-[120px] w-full overflow-auto rounded border border-input bg-white dark:bg-gray-900 [&_svg]:max-w-full [&_svg]:h-auto"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
) : null}
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<div className="w-full text-xs text-muted-foreground">Parsed YAML output</div> <div className="w-full text-xs text-muted-foreground">
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'}
</div>
</BaseNodeFooter> </BaseNodeFooter>
<InputHandle id="ain" /> <InputHandle id="ain" />

View File

@@ -0,0 +1,41 @@
import { StreamLanguage } from '@codemirror/language'
/** Simple PlantUML stream parser for syntax highlighting in CodeMirror */
const plantumlParser = StreamLanguage.define({
name: 'plantuml',
token(stream) {
// Single-quote line comment (PlantUML)
if (stream.match(/^'/)) {
stream.skipToEnd()
return 'comment'
}
// Double-quoted string
if (stream.match(/^"/)) {
let escaped = false
while (!stream.eol()) {
if (escaped) {
escaped = false
stream.next()
continue
}
const ch = stream.next()
if (ch === '\\') escaped = true
else if (ch === '"') break
}
return 'string'
}
// @directives (@startuml, @enduml, etc.)
if (stream.match(/^@\w+/)) return 'meta'
// Skip whitespace
if (stream.eatSpace()) return null
// Arrows and connectors
if (stream.match(/^->>?|<-<?|-->>?|<<--?|<-?>/)) return 'keyword'
// Keywords (participant, actor, as, title, etc.)
if (stream.match(/^(participant|actor|as|title|autonumber|left|right|of|over|activate|deactivate|destroy|create|group|opt|alt|else|loop|par|end|note|legend|skinparam|start|stop|if|endif|elseif|while|endwhile|repeat|until|switch|case|endswitch|class|interface|enum|package|namespace|abstract|static|extends|implements)\b/i)) return 'keyword'
// Any other character (identifier, punctuation, etc.)
stream.next()
return null
},
})
export const plantumlLanguage = plantumlParser

View File

@@ -5,6 +5,16 @@ import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
proxy: {
// Avoid CORS: browser calls same origin, Vite forwards to Kroki
'/api/kroki': {
target: 'https://kroki.io',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/kroki/, ''),
},
},
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),