switch to plantuml
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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];' },
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,118 +120,148 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
runIdRef.current += 1
|
||||||
const configIdsUsed = new Set<string>()
|
const thisRunId = runIdRef.current
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
const resolveIncludes = (text: string, visited = new Set<string>()): string => {
|
const run = async () => {
|
||||||
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
|
setLoading(true)
|
||||||
return text.replace(includeRegex, (match: string, indent: string, ref: string) => {
|
setError(null)
|
||||||
const refName = ref.endsWith('.yaml') ? ref.slice(0, -5) : ref
|
try {
|
||||||
const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName)
|
const configIdsUsed = new Set<string>()
|
||||||
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 resolveIncludes = (text: string, visited = new Set<string>()): string => {
|
||||||
const q: string[] = [startId]
|
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
|
||||||
const seen = new Set<string>([startId])
|
return text.replace(includeRegex, (match: string, indent: string, ref: string) => {
|
||||||
while (q.length) {
|
const refName = ref.endsWith('.puml') ? ref.slice(0, -5) : ref
|
||||||
const cur = q.shift()!
|
const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName)
|
||||||
if (cur === targetId) return true
|
if (!refNode) throw new Error(`Included node not found: ${ref}`)
|
||||||
for (const e of edges) {
|
if (visited.has(refNode.id)) throw new Error(`Circular include detected: ${ref}`)
|
||||||
if (e.source === cur && !seen.has(e.target)) {
|
|
||||||
seen.add(e.target)
|
const isReachable = (startId: string, targetId: string) => {
|
||||||
q.push(e.target)
|
const q: string[] = [startId]
|
||||||
|
const seen = new Set<string>([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)
|
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)
|
||||||
|
|
||||||
const indented = resolved
|
const indented = resolved
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line: string) => (line === '' ? '' : indent + line))
|
.map((line: string) => (line === '' ? '' : indent + line))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
return indented
|
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<string, string> = {}
|
const varMap: Record<string, string> = {}
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
if (configIdsUsed.has(e.target)) {
|
if (configIdsUsed.has(e.target)) {
|
||||||
const src = nodes.find((n: any) => n.id === e.source)
|
const src = nodes.find((n: any) => n.id === e.source)
|
||||||
if (src?.type === 'variable') {
|
if (src?.type === 'variable') {
|
||||||
const v = src.data?.value
|
const v = src.data?.value
|
||||||
varMap[src.id] = v === undefined || v === null ? '' : String(v)
|
varMap[src.id] = v === undefined || v === null ? '' : String(v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const resolveFunctionCalls = (text: string): string => {
|
const resolveFunctionCalls = (text: string): string => {
|
||||||
const fnCallRegex = /\$\{([\w-]+)\s*\(([^)]*)\)\}/g
|
const fnCallRegex = /\$\{([\w-]+)\s*\(([^)]*)\)\}/g
|
||||||
return text.replace(fnCallRegex, (match, funcId: string, argsStr: string) => {
|
return text.replace(fnCallRegex, (match, funcId: string, argsStr: string) => {
|
||||||
const fnNode = nodes.find((n: any) => n.id === funcId && n.type === 'function')
|
const fnNode = nodes.find((n: any) => n.id === funcId && n.type === 'function')
|
||||||
if (!fnNode) return match
|
if (!fnNode) return match
|
||||||
const body = fnNode.data?.body ?? 'return args[0];'
|
const body = fnNode.data?.body ?? 'return args[0];'
|
||||||
const argIds = argsStr.split(',').map((s: string) => s.trim()).filter(Boolean)
|
const argIds = argsStr.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
const argValues = argIds.map((argId: string) => varMap[argId] ?? '')
|
const argValues = argIds.map((argId: string) => varMap[argId] ?? '')
|
||||||
try {
|
try {
|
||||||
const fn = new Function('args', body)
|
const fn = new Function('args', body)
|
||||||
const result = fn(argValues)
|
const result = fn(argValues)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
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" />
|
||||||
@@ -246,4 +332,4 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
|
|
||||||
RenderingNode.displayName = 'RenderingNode'
|
RenderingNode.displayName = 'RenderingNode'
|
||||||
|
|
||||||
export default RenderingNode
|
export default RenderingNode
|
||||||
|
|||||||
41
src/lib/plantumlLanguage.ts
Normal file
41
src/lib/plantumlLanguage.ts
Normal 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
|
||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user