diff --git a/package-lock.json b/package-lock.json
index eca1d98..d5a241f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
+ "@codemirror/lang-markdown": "^6.5.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-select": "^2.2.6",
@@ -20,6 +21,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
+ "marked": "^17.0.4",
"nunjucks": "^3.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
@@ -572,6 +574,36 @@
"@lezer/common": "^1.1.0"
}
},
+ "node_modules/@codemirror/lang-css": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+ "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/css": "^1.1.7"
+ }
+ },
+ "node_modules/@codemirror/lang-html": {
+ "version": "6.4.11",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
+ "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/css": "^1.1.0",
+ "@lezer/html": "^1.3.12"
+ }
+ },
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
@@ -587,6 +619,21 @@
"@lezer/javascript": "^1.0.0"
}
},
+ "node_modules/@codemirror/lang-markdown": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
+ "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.7.1",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.3.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/markdown": "^1.0.0"
+ }
+ },
"node_modules/@codemirror/language": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
@@ -1444,6 +1491,17 @@
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
+ "node_modules/@lezer/css": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
+ "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
@@ -1453,6 +1511,17 @@
"@lezer/common": "^1.3.0"
}
},
+ "node_modules/@lezer/html": {
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
+ "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
@@ -1473,6 +1542,16 @@
"@lezer/common": "^1.0.0"
}
},
+ "node_modules/@lezer/markdown": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
+ "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
@@ -5764,6 +5843,18 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/marked": {
+ "version": "17.0.4",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
+ "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/package.json b/package.json
index 7a4f1af..d7078b3 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
+ "@codemirror/lang-markdown": "^6.5.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-select": "^2.2.6",
@@ -20,6 +21,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
+ "marked": "^17.0.4",
"nunjucks": "^3.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
diff --git a/src/components/graph/BaseNode.tsx b/src/components/graph/BaseNode.tsx
index 80ef236..9829ba1 100644
--- a/src/components/graph/BaseNode.tsx
+++ b/src/components/graph/BaseNode.tsx
@@ -115,10 +115,13 @@ export function BaseNodeHeader({
export function BaseNodeHeaderRow({
icon,
title,
+ right,
className,
}: {
icon: ReactNode;
title: ReactNode;
+ /** Optional right-side content (e.g. type selector). */
+ right?: ReactNode;
className?: string;
}) {
return (
@@ -131,10 +134,11 @@ export function BaseNodeHeaderRow({
{icon}
{title}
+ {right != null ? {right}
: null}
);
}
diff --git a/src/components/graph/ConfigNode.tsx b/src/components/graph/ConfigNode.tsx
index 5691305..cf0c9eb 100644
--- a/src/components/graph/ConfigNode.tsx
+++ b/src/components/graph/ConfigNode.tsx
@@ -1,12 +1,22 @@
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror'
+import { markdown } from '@codemirror/lang-markdown'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
import { plantumlLanguage } from '../../lib/plantumlLanguage'
import { useTheme } from '../../lib/themeContext'
+import {
+ CONFIG_TYPES,
+ getConfigContent,
+ getConfigType,
+ getConfigTypeId,
+ getDefaultContentForConfigType,
+ isGroup,
+ type ConfigTypeId,
+} from '../../lib/configTypes'
import {
BaseNode,
BaseNodeContent,
@@ -20,6 +30,7 @@ import {
MenubarSubContent,
MenubarSubTrigger,
} from '../ui/menubar'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle'
@@ -32,10 +43,10 @@ type Props = {
height?: number
}
-const DEFAULT_PLANTUML = '@startuml\n\n@enduml\n'
-
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
- const plantumlValue = data?.plantuml ?? DEFAULT_PLANTUML
+ const configTypeId = getConfigTypeId(data)
+ const configType = getConfigType(configTypeId)
+ const content = getConfigContent(data) || getDefaultContentForConfigType(configTypeId)
const { theme } = useTheme()
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
@@ -63,10 +74,37 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
const onChange = useCallback(
(val: string) => {
if (setNodes) {
- setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, plantuml: val } } : n)))
+ setNodes((nds: any[]) =>
+ nds.map((n) =>
+ n.id === id ? { ...n, data: { ...n.data, content: val, configType: configTypeId } } : n
+ )
+ )
}
},
- [id, setNodes]
+ [id, setNodes, configTypeId]
+ )
+
+ const setConfigType = useCallback(
+ (newTypeId: ConfigTypeId) => {
+ if (!setNodes || newTypeId === configTypeId) return
+ setNodes((nds: any[]) =>
+ nds.map((n) =>
+ n.id === id
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ configType: newTypeId,
+ content:
+ getConfigContent(n.data) ||
+ getDefaultContentForConfigType(newTypeId),
+ },
+ }
+ : n
+ )
+ )
+ },
+ [id, setNodes, configTypeId]
)
const insertAt = useCallback(
@@ -91,12 +129,12 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
return
}
if (mode === 'prepend') {
- onChange(insertText + plantumlValue)
+ onChange(insertText + content)
} else {
- onChange(plantumlValue + insertText)
+ onChange(content + insertText)
}
},
- [onChange, plantumlValue]
+ [onChange, content]
)
const insertExtendsFromNode = useCallback(
@@ -142,30 +180,44 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
)
const extensions = useMemo(
() => [
- plantumlLanguage.extension,
+ configType.language === 'plantuml' ? plantumlLanguage.extension : markdown(),
autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
activateOnTyping: true,
}),
],
- [variableIds, functionIds, configTitles],
+ [configType.language, variableIds, functionIds, configTitles],
)
const [editorHeight, editorContainerRef] = useResizeHeight(180)
- const nunjucksTagSnippets = useMemo(
- () => [
- { label: 'Variable {{ }}', snippet: '{{ }}' },
- { label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
- { label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
- { label: 'set', snippet: '{% set = %}' },
- { label: 'block / endblock', snippet: '{% block %}\n \n{% endblock %}' },
- { label: 'extends', snippet: '{% extends "" %}' },
- { label: 'include', snippet: '{% include "" %}' },
- { label: 'import', snippet: '{% import "" as %}' },
- { label: 'raw / endraw', snippet: '{% raw %}\n \n{% endraw %}' },
- ],
- []
- )
+ const insertBlocksContent = useMemo(() => {
+ return configType.insertBlocks.map((block, idx) =>
+ isGroup(block) ? (
+
+ {block.label}
+
+ {block.items.map(({ label, snippet }) => (
+ insertAt(snippet, 'cursor')}
+ >
+ {label}
+
+ ))}
+
+
+ ) : (
+ insertAt(block.snippet, 'cursor')}
+ >
+ {block.label}
+
+ )
+ )
+ }, [configType.insertBlocks, insertAt])
const dimensions =
width != null && height != null && width > 0 && height > 0
@@ -174,7 +226,24 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
return (
>}>
- } title={} />
+ }
+ title={}
+ right={
+
+ }
+ />
@@ -247,19 +316,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
>
) : undefined
}
- insertTagsContent={
- <>
- {nunjucksTagSnippets.map(({ label, snippet }) => (
- insertAt(snippet, 'cursor')}
- >
- {label}
-
- ))}
- >
- }
+ insertTagsContent={insertBlocksContent}
+ insertTagsLabel={configType.label}
/>
@@ -267,7 +325,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
- {plantumlValue ? `${plantumlValue.length} chars` : 'none'}
+ {content ? `${content.length} chars` : 'none'}
diff --git a/src/components/graph/NodeMenubar.tsx b/src/components/graph/NodeMenubar.tsx
index 64b9c24..f26b2d5 100644
--- a/src/components/graph/NodeMenubar.tsx
+++ b/src/components/graph/NodeMenubar.tsx
@@ -22,13 +22,15 @@ type Props = {
nodeType: NodeType
/** Content for Insert → Inputs (config and function nodes only) */
editInputsContent?: React.ReactNode
- /** Content for Insert → Tags (e.g. Nunjucks tag snippets, config nodes) */
+ /** Content for Insert → [Blocks/Tags] (e.g. type-specific snippets, config nodes) */
insertTagsContent?: React.ReactNode
+ /** Label for Insert submenu that shows insertTagsContent (default "Tags") */
+ insertTagsLabel?: string
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
nodeMenuExtraContent?: React.ReactNode
}
-export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsContent, nodeMenuExtraContent }: Props) {
+export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsContent, insertTagsLabel = 'Tags', nodeMenuExtraContent }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
@@ -123,7 +125,7 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, insertTagsCon
{insertTagsContent != null && (
- Tags
+ {insertTagsLabel}
{insertTagsContent}
diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx
index 8502901..a62588d 100644
--- a/src/components/graph/RenderingNode.tsx
+++ b/src/components/graph/RenderingNode.tsx
@@ -1,6 +1,7 @@
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks'
import FlowContext from '../../lib/flowContext'
+import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
import {
BaseNode,
@@ -17,9 +18,6 @@ import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSu
import { Sparkles } from 'lucide-react'
import { InputHandle } 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
@@ -27,7 +25,7 @@ type Props = {
}
export const RenderingNode = memo(function RenderingNode({ id, width, height }: Props) {
- const [svgContent, setSvgContent] = useState(null)
+ const [renderedContent, setRenderedContent] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
const runIdRef = useRef(0)
@@ -44,7 +42,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
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 configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
+ const sourceContent = srcNode?.type === 'config' ? getConfigContent(srcNode.data) : ''
const srcData = srcNode?.data ?? {}
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
@@ -86,7 +85,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
if (!node) return
visited.add(nodeId)
out.add(nodeId)
- const content = String(node.data?.plantuml ?? '')
+ const content = getConfigContent(node.data)
for (const ref of getTemplateRefs(content)) {
const refId = resolveRef(ref)
if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id))
@@ -109,7 +108,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
() =>
nodes
.filter((n: any) => n.type === 'config' && connectedNodeIds.has(n.id))
- .map((n: any) => `${n.id}:${n.data?.title ?? ''}:${n.data?.plantuml ?? ''}`)
+ .map((n: any) => `${n.id}:${n.data?.title ?? ''}:${getConfigContent(n.data)}`)
.sort()
.join('|'),
[nodes, connectedNodeIds]
@@ -146,15 +145,15 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
)
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' })
+ if (!sourceContent && incomingIds.length > 0) {
+ setRenderedContent(null)
+ setError({ kind: 'no-content', message: 'No content on connected configuration node' })
+ setLoading(false)
+ return
+ }
+ if (incomingIds.length === 0) {
+ setRenderedContent(null)
+ setError(null)
setLoading(false)
return
}
@@ -213,7 +212,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
visited.add(refId)
configIdsUsed.add(refId)
- const content = String(node.data?.plantuml ?? '')
+ const content = getConfigContent(node.data)
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
}
@@ -228,7 +227,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
if (refId !== srcId && !isReachable(refId, id))
throw new Error(`Referenced config not connected to renderer: ${name}`)
return {
- src: String(node.data?.plantuml ?? ''),
+ src: getConfigContent(node.data),
path: name,
}
},
@@ -428,30 +427,18 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
return
}
- const resolvedPlantuml = afterNunjucks
+ const resolvedContent = afterNunjucks
+ const typeRenderer = getConfigType(configTypeId)
try {
- const res = await fetch(KROKI_PLANTUML_SVG, {
- method: 'POST',
- headers: { 'Content-Type': 'text/plain' },
- body: resolvedPlantuml,
- })
-
+ const htmlOrSvg = await typeRenderer.render(resolvedContent)
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)
+ setRenderedContent(htmlOrSvg)
setError(null)
} catch (err: any) {
if (cancelled || thisRunId !== runIdRef.current) return
- const msg = err?.message ?? 'PlantUML render error'
- setSvgContent(null)
+ const msg = err?.message ?? 'Render error'
+ setRenderedContent(null)
setError({ kind: 'render', message: msg })
} finally {
if (!cancelled && thisRunId === runIdRef.current) {
@@ -471,8 +458,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
})
} catch (err: any) {
if (!cancelled && thisRunId === runIdRef.current) {
- setSvgContent(null)
- setError({ kind: 'render', message: err?.message ?? 'PlantUML render error' })
+ setRenderedContent(null)
+ setError({ kind: 'render', message: err?.message ?? 'Render error' })
setLoading(false)
}
}
@@ -486,29 +473,31 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
minLoadingTimeoutRef.current = null
}
}
- // Only re-run when inputs that affect the resolved diagram change (signatures + source).
+ // Only re-run when inputs that affect the resolved output change (signatures + source).
// Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders.
- }, [id, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature])
+ }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature])
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
+ const isSvgOutput = Boolean(renderedContent?.trim().startsWith('