feat: add node help system and registry for extensible node types

- Implemented a help system for different node types (config, render, variable, function) with detailed usage instructions.
- Created a node registry to manage node types, including registration, retrieval, and validation of connections between nodes.
- Defined central node and edge types for the application to streamline state management.
- Added Nunjucks autocomplete functionality to enhance user experience in template editing.
- Developed a syntax highlighting parser for PlantUML and Nunjucks within the CodeMirror editor.
- Registered built-in node types at application startup, including their default configurations and help entries.
- Introduced a theme context provider to manage light/dark mode preferences across the application.
- Created utility functions for class name management using clsx and tailwind-merge.
- Set up Tailwind CSS for styling with custom themes and responsive design.
- Configured Vite for development with proxy settings for backend API calls and Kroki diagram service.
This commit is contained in:
2026-03-09 20:04:31 +01:00
parent 11fd9cd54d
commit b3c2c6711f
67 changed files with 1286 additions and 2124 deletions

View File

@@ -0,0 +1,135 @@
/**
* Abstract node layer: shared types, hook, and factory for flow node components.
*
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds.
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
*
* Example: define NodeData type, Props = AbstractNodeProps<NodeData>, use useAbstractNode in the
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
*/
import React, { useCallback, useContext, useMemo } from 'react'
import FlowContext from './flowContext'
import { nodePropsAreEqual } from './flowUtils'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props passed by React Flow to custom node components. Extend data with your node's shape. */
export type AbstractNodeProps<TData = Record<string, unknown>> = {
id: string
data: TData
width?: number
height?: number
selected?: boolean
}
/** Edge shape used in flow context (minimal for connection logic). */
export type FlowEdge = { id: string; source: string; target: string; [k: string]: unknown }
/** Node shape used in flow context (minimal for reading graph). */
export type FlowNode = { id: string; type?: string; data?: unknown; position?: { x: number; y: number }; [k: string]: unknown }
/** Result of useAbstractNode: flow context plus helpers scoped to this node. */
export type AbstractNodeContext<TData = Record<string, unknown>> = {
id: string
data: TData
nodes: FlowNode[]
edges: FlowEdge[]
setNodes: (updater: (nodes: FlowNode[]) => FlowNode[]) => void
setEdges: (updater: (edges: FlowEdge[]) => FlowEdge[]) => void
/** Merge partial data into this node's data. Stable reference. */
updateData: (partial: Partial<TData>) => void
/** Incoming edge IDs (edges whose target is this node). */
incomingEdges: FlowEdge[]
/** Outgoing edge IDs (edges whose source is this node). */
outgoingEdges: FlowEdge[]
/** Source node IDs connected to this node (incoming). */
sourceIds: string[]
/** Target node IDs this node connects to (outgoing). */
targetIds: string[]
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Provides flow context and helpers for the current node. Use in any node component
* that receives id and data; updateData(partial) merges into this node's data.
*/
export function useAbstractNode<TData = Record<string, unknown>>(
id: string,
data: TData
): AbstractNodeContext<TData> {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const edges = ctx?.edges ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const updateData = useCallback(
(partial: Partial<TData>) => {
if (!setNodes) return
setNodes((nds: FlowNode[]) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
)
)
},
[id, setNodes]
)
const incomingEdges = useMemo(
() => (edges as FlowEdge[]).filter((e) => e.target === id),
[edges, id]
)
const outgoingEdges = useMemo(
() => (edges as FlowEdge[]).filter((e) => e.source === id),
[edges, id]
)
const sourceIds = useMemo(
() => incomingEdges.map((e) => e.source).sort(),
[incomingEdges]
)
const targetIds = useMemo(
() => outgoingEdges.map((e) => e.target).sort(),
[outgoingEdges]
)
return {
id,
data,
nodes,
edges,
setNodes: setNodes ?? (() => {}),
setEdges: setEdges ?? (() => {}),
updateData,
incomingEdges,
outgoingEdges,
sourceIds,
targetIds,
}
}
// ---------------------------------------------------------------------------
// Component factory
// ---------------------------------------------------------------------------
/**
* Wraps a node component with React.memo and nodePropsAreEqual so only id/data/width/height/selected
* changes trigger re-renders. Use with AbstractNodeProps<TData> for typed props.
*/
export function createAbstractNodeComponent<TData = Record<string, unknown>>(
displayName: string,
Component: React.ComponentType<AbstractNodeProps<TData>>
): React.MemoExoticComponent<React.ComponentType<AbstractNodeProps<TData>>> {
const Wrapped = React.memo(Component, nodePropsAreEqual) as React.MemoExoticComponent<
React.ComponentType<AbstractNodeProps<TData>>
>
Wrapped.displayName = displayName
return Wrapped
}

View File

@@ -0,0 +1,232 @@
/**
* Registry of config node types. Each type defines syntax highlighting,
* insert-menu blocks, and how to render the content (after Nunjucks) for the renderer.
* Add a new entry here and wire its language in ConfigNode to add a new config type.
*/
export type ConfigTypeId = 'plantuml' | 'markdown' | 'wireframe'
/** A single insert option (label + snippet to insert at cursor). */
export type InsertBlock = { label: string; snippet: string }
/** A group of insert options (e.g. "Diagram" with startuml, actor, etc.). */
export type InsertBlockGroup = { label: string; items: InsertBlock[] }
export type InsertBlockOrGroup = InsertBlock | InsertBlockGroup
function isGroup(b: InsertBlockOrGroup): b is InsertBlockGroup {
return 'items' in b && Array.isArray((b as InsertBlockGroup).items)
}
/** Optional options passed to render (e.g. node size for wireframe SVG). */
export type RenderOptions = { width?: number; height?: number }
export type ConfigType = {
id: ConfigTypeId
label: string
/** CodeMirror language key; used to pick the extension in ConfigNode. */
language: ConfigTypeId
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */
insertBlocks: InsertBlockOrGroup[]
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
render: (content: string, options?: RenderOptions) => Promise<string>
}
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
const KROKI_TIMEOUT_MS = 15000
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
{ label: 'Actor', snippet: 'actor ' },
{ label: 'Participant', snippet: 'participant "" as ' },
{ label: 'Arrow', snippet: ' -> ' },
{ label: 'Note', snippet: 'note right of ' },
{
label: 'Templating',
items: [
{ 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: 'extends', snippet: '{% extends "" %}' },
{ label: 'include', snippet: '{% include "" %}' },
{ label: 'import', snippet: '{% import "" as %}' },
],
},
]
const MARKDOWN_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Heading 1', snippet: '# ' },
{ label: 'Heading 2', snippet: '## ' },
{ label: 'Heading 3', snippet: '### ' },
{ label: 'Bold', snippet: '****' },
{ label: 'Italic', snippet: '**' },
{ label: 'Code inline', snippet: '``' },
{ label: 'Code block', snippet: '```\n\n```' },
{ label: 'Link', snippet: '[]()' },
{ label: 'Image', snippet: '![]()' },
{ label: 'List item', snippet: '- ' },
{ label: 'Blockquote', snippet: '> ' },
{
label: 'Templating',
items: [
{ label: 'Variable {{ }}', snippet: '{{ }}' },
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
{ label: 'set', snippet: '{% set = %}' },
],
},
]
/** Wireweave DSL: https://github.com/wireweave/core */
const WIREFRAME_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Page', snippet: 'page "Title" {\n \n}' },
{ label: 'Card', snippet: 'card p=4 {\n \n}' },
{ label: 'Title', snippet: 'title "' },
{ label: 'Text', snippet: 'text "' },
{ label: 'Button', snippet: 'button "Label"' },
{ label: 'Primary button', snippet: 'button "Label" primary' },
{ label: 'Input', snippet: 'input placeholder=""' },
{ label: 'Row', snippet: 'row {\n col span=6 { }\n}' },
{ label: 'Col', snippet: 'col span=6 { }' },
{ label: 'Header / Main / Footer', snippet: 'header { }\nmain { }\nfooter { }' },
{ label: 'Image', snippet: 'image "" w=400 h=300' },
{
label: 'Templating',
items: [
{ label: 'Variable {{ }}', snippet: '{{ }}' },
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
{ label: 'set', snippet: '{% set = %}' },
],
},
]
/** Markdown to HTML using dynamic import to avoid loading if only PlantUML is used. */
async function renderMarkdown(content: string): Promise<string> {
const { marked } = await import('marked')
const html = typeof marked.parse === 'function' ? await marked.parse(content) : (marked as (s: string) => string)(content)
return typeof html === 'string' ? html : String(html)
}
/** Wireweave DSL to SVG; theme from document dark mode. See https://www.wireweave.org/ */
async function renderWireframe(content: string, options?: RenderOptions): Promise<string> {
const { parse, renderToSvg } = await import('@wireweave/core')
const doc = parse(content)
const isDark =
typeof document !== 'undefined' &&
document.documentElement?.classList?.contains('dark')
const { svg } = renderToSvg(doc, {
theme: isDark ? 'dark' : 'light',
width: options?.width ?? 1200,
height: options?.height,
padding: 24,
})
return svg
}
export const CONFIG_TYPES: ConfigType[] = [
{
id: 'plantuml',
label: 'Diagram',
language: 'plantuml',
insertBlocks: PLANTUML_INSERT_BLOCKS,
render: async (content: string) => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
try {
const res = await fetch(KROKI_PLANTUML_SVG, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: content,
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!res.ok) {
const err = await res.text()
if (res.status >= 500) {
throw new Error('Diagram service unavailable. Try again later.')
}
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
}
return res.text()
} catch (err: unknown) {
clearTimeout(timeoutId)
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
}
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
throw new Error('Diagram service unavailable. Check your connection or try again later.')
}
}
throw err
}
},
},
{
id: 'markdown',
label: 'Markdown',
language: 'markdown',
insertBlocks: MARKDOWN_INSERT_BLOCKS,
render: renderMarkdown,
},
{
id: 'wireframe',
label: 'Wireframe',
language: 'markdown',
insertBlocks: WIREFRAME_INSERT_BLOCKS,
render: renderWireframe,
},
]
export const CONFIG_TYPE_IDS = CONFIG_TYPES.map((t) => t.id)
export const DEFAULT_CONFIG_TYPE_ID: ConfigTypeId = 'plantuml'
export function getConfigType(id: ConfigTypeId): ConfigType {
const t = CONFIG_TYPES.find((c) => c.id === id)
if (!t) throw new Error(`Unknown config type: ${id}`)
return t
}
export function getDefaultContentForConfigType(configTypeId: ConfigTypeId): string {
if (configTypeId === 'plantuml') return '@startuml\n\n@enduml\n'
if (configTypeId === 'markdown') return ''
if (configTypeId === 'wireframe') {
return `page "Hello" {
card p=4 {
title "Welcome"
text "Hello, wireweave!"
button "Get Started" primary
}
}`
}
return ''
}
/** Flatten insert blocks for iteration: either a single block or a group's items. */
export function* iterateInsertBlocks(blocks: InsertBlockOrGroup[]): Generator<InsertBlock> {
for (const b of blocks) {
if (isGroup(b)) {
for (const item of b.items) yield item
} else {
yield b
}
}
}
export { isGroup }
/** Content from config node data (backward compat: content ?? plantuml). */
export function getConfigContent(data: Record<string, unknown> | undefined): string {
if (!data) return ''
const content = data.content ?? data.plantuml
return typeof content === 'string' ? content : ''
}
/** Config type id from data (default plantuml). */
export function getConfigTypeId(data: Record<string, unknown> | undefined): ConfigTypeId {
if (!data || data.configType == null) return 'plantuml'
const id = data.configType
return CONFIG_TYPE_IDS.includes(id as ConfigTypeId) ? (id as ConfigTypeId) : 'plantuml'
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import type { Connection } from '@xyflow/react'
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
export type FlowActions = {
pasteAtViewportCenter: () => void
fitView: () => void
}
export type FlowContextValue = {
nodes: AppNode[]
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
edges: AppEdge[]
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
renamingNodeId: string | null
setRenamingNodeId: (id: string | null) => void
/** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */
connectionFrom: ConnectionFrom
setConnectionFrom: (v: ConnectionFrom) => void
isValidConnection: (connection: Connection) => boolean
/** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */
flowActionsRef: React.MutableRefObject<FlowActions | null>
}
const FlowContext = React.createContext<FlowContextValue | null>(null)
export default FlowContext

View File

@@ -0,0 +1,97 @@
/**
* Use as second argument to React.memo() for node components.
* Skips re-render when only position (or other unrelated props) changed,
* so dragging one node doesn't force other nodes to re-render.
*/
import {
getIdPrefix,
getDefaultDataForType as getDefaultDataFromRegistry,
getResetDataForType as getResetDataFromRegistry,
getDefaultStyle,
} from './nodeRegistry'
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
prev: P,
next: P
): boolean {
return (
prev.id === next.id &&
prev.data === next.data &&
prev.width === next.width &&
prev.height === next.height &&
prev.selected === next.selected
)
}
/** @deprecated Use getIdPrefix from nodeRegistry for new code. Kept for compatibility. */
export const PREFIX_BY_TYPE: Record<string, string> = {
config: 'cfg_',
render: 'rnd_',
variable: 'var_',
function: 'fn_',
}
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …). Uses nodeRegistry for prefix when available. */
export function getNextNodeId(type: string, existingIds: string[]): string {
const prefix = getIdPrefix(type)
const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)
let max = 0
for (const id of existingIds) {
const m = id.match(re)
if (m) max = Math.max(max, parseInt(m[1], 10))
}
return `${prefix}${String(max + 1).padStart(3, '0')}`
}
/** Recursively replace oldId with newId in string values (for rename propagation) */
function replaceInData(value: unknown, oldId: string, newId: string): unknown {
if (typeof value === 'string') return value.split(oldId).join(newId)
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) return value.map((v) => replaceInData(v, oldId, newId))
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = replaceInData(v, oldId, newId)
return out
}
/** Update graph after renaming a node: change node id and all references in data and edges */
export function replaceNodeIdInGraph(
nodes: Array<{ id: string; data?: any; [k: string]: any }>,
edges: Array<{ id: string; source: string; target: string; [k: string]: any }>,
oldId: string,
newId: string
): { nodes: typeof nodes; edges: typeof edges } {
const newNodes = nodes.map((n) =>
n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any }
)
const newEdges = edges.map((e) => ({
...e,
id: e.id.includes(oldId) ? e.id.split(oldId).join(newId) : e.id,
source: e.source === oldId ? newId : e.source,
target: e.target === oldId ? newId : e.target,
}))
return { nodes: newNodes, edges: newEdges }
}
/** @deprecated Use getDefaultStyle from nodeRegistry. Kept for compatibility. */
export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
config: { width: 320, height: 320 },
render: { width: 384, height: 320 },
variable: { width: 224, height: 180 },
function: { width: 288, height: 260 },
}
/** Default data for a new node. Uses nodeRegistry when type is registered. */
export function getDefaultDataForType(type: string, newId?: string): any {
return getDefaultDataFromRegistry(type, newId)
}
/** Data for Reset action. Uses nodeRegistry when type is registered. */
export function getResetDataForType(type: string, nodeId?: string): any {
return getResetDataFromRegistry(type, nodeId)
}
/** Default style for a type. Uses nodeRegistry when type is registered. */
export function getDefaultStyleForType(type: string): { width: number; height: number } {
return getDefaultStyle(type)
}

View File

@@ -0,0 +1,84 @@
import type React from 'react'
export type NodeType = 'config' | 'render' | 'variable' | 'function'
export type NodeHelpEntry = {
title: string
content: React.ReactNode
}
const Code = ({ children }: { children: React.ReactNode }) => (
<code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">{children}</code>
)
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div className="mt-3 first:mt-0">
<h4 className="text-xs font-semibold text-foreground">{title}</h4>
<div className="mt-1 text-xs text-muted-foreground">{children}</div>
</div>
)
export const NODE_HELP: Record<NodeType, NodeHelpEntry> = {
config: {
title: 'Config node',
content: (
<>
<Section title="How to use">
<p>Config nodes hold PlantUML + Nunjucks template content. Connect one config to a Render node to display the diagram. Use the editor to write <Code>@startuml</Code> blocks and Nunjucks tags (<Code>{'{{ }}'}</Code>, <Code>{'{% %}'}</Code>).</p>
</Section>
<Section title="Using in another node">
<p>From another config, reference this template:</p>
<ul className="list-disc pl-4 mt-1 space-y-0.5">
<li><Code>{'{% extends "configId" %}'}</Code> inherit layout</li>
<li><Code>{'{% include "configId" %}'}</Code> inline content</li>
<li><Code>{'{% import "configId" as alias %}'}</Code> use as macro namespace</li>
</ul>
<p className="mt-2">Replace <Code>configId</Code> with this nodes id or its title. Connect variables/functions to this config; they are available as <Code>{'{{ varId }}'}</Code> and <Code>{'{{ x | fnId }}'}</Code> in the template.</p>
</Section>
</>
),
},
render: {
title: 'Renderer node',
content: (
<>
<Section title="How to use">
<p>Connect a single Config node (input) to this Render node. It resolves the configs PlantUML + Nunjucks (variables, function filters, extends/include), sends the result to the diagram service, and shows the SVG here.</p>
</Section>
<Section title="Using in another node">
<p>Renderer nodes are terminal: they only consume configs. They are not referenced from other nodes. To reuse a diagram, reference the Config node from another Config (extends/include), then connect that config to a Render node.</p>
</Section>
</>
),
},
variable: {
title: 'Variable node',
content: (
<>
<Section title="How to use">
<p>Variables hold a value (string, number, or boolean). Connect a Variable node to a Config node to expose it in that configs template.</p>
</Section>
<Section title="Using in another node">
<p>In a Config template connected to this variable, use <Code>{'{{ '}<em>nodeId</em>{' }}'}</Code> where <em>nodeId</em> is this nodes id. Example: if the variable node id is <Code>var_001</Code>, write <Code>{'{{ var_001 }}'}</Code> in the config.</p>
</Section>
</>
),
},
function: {
title: 'Function node',
content: (
<>
<Section title="How to use">
<p>Functions are Nunjucks custom filters. Write a body using either named parameters (<Code>function(num, x, kwargs) &#123; ... &#125;</Code>) or the <Code>args</Code> array. Connect this node to a Config to use the filter in that configs template.</p>
</Section>
<Section title="Using in another node">
<p>In a Config template, use the filter syntax: <Code>{'{{ value | '}<em>nodeId</em>{' }}'}</Code> or <Code>{'{{ value | '}<em>nodeId</em>{'(arg1, key=val) }}'}</Code>. The first argument is the value before <Code>|</Code>; extra arguments and keyword args are passed as in Nunjucks. Return a value or a Promise for async filters.</p>
</Section>
</>
),
},
}
export function getNodeHelp(nodeType: NodeType): NodeHelpEntry {
return NODE_HELP[nodeType] ?? { title: nodeType, content: null }
}

View File

@@ -0,0 +1,116 @@
/**
* Extensible node type registry. Register node types with registerNodeType();
* built-in types are registered in registerBuiltinNodes.ts.
* Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI.
*/
import type React from 'react'
import type { Node } from '@xyflow/react'
export type NodeHelpEntry = {
title: string
content: React.ReactNode
}
export type NodeTypeDescriptor = {
id: string
component: React.ComponentType<any>
defaultStyle: { width: number; height: number }
defaultData: Record<string, unknown>
idPrefix: string
hasInput: boolean
hasOutput: boolean
/** When this type is the connection target, which source types are allowed. Omit = all. */
allowedSourceTypes?: string[]
/** When this type is the connection source, which target types are allowed. Omit = all. */
allowedTargetTypes?: string[]
help: NodeHelpEntry
menuLabel: string
menuIcon: React.ReactNode
/** Optional: override default data for new nodes (e.g. set title from newId). */
getDefaultData?: (newId?: string) => Record<string, unknown>
/** Optional: data for Reset action; if omitted, getDefaultData(nodeId) or defaultData is used. */
getResetData?: (nodeId?: string) => Record<string, unknown>
/** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */
connectionLabel?: string
}
const registry = new Map<string, NodeTypeDescriptor>()
export function registerNodeType(descriptor: NodeTypeDescriptor): void {
if (registry.has(descriptor.id)) {
console.warn(`[nodeRegistry] Overwriting existing node type: ${descriptor.id}`)
}
registry.set(descriptor.id, descriptor)
}
export function getNodeType(id: string): NodeTypeDescriptor | undefined {
return registry.get(id)
}
export function getRegisteredNodeTypes(): NodeTypeDescriptor[] {
return Array.from(registry.values())
}
export function getRegisteredNodeTypeIds(): string[] {
return Array.from(registry.keys())
}
/** Default data for a new node of this type. Uses getDefaultData from descriptor if provided. */
export function getDefaultDataForType(type: string, newId?: string): Record<string, unknown> {
const desc = registry.get(type)
if (!desc) return {}
if (desc.getDefaultData) return { ...desc.getDefaultData(newId) }
const base = { ...desc.defaultData }
if (type === 'config' && newId) (base as any).title = `${newId}`
return base
}
/** Data for Reset action. Uses getResetData from descriptor if provided. */
export function getResetDataForType(type: string, nodeId?: string): Record<string, unknown> {
const desc = registry.get(type)
if (!desc) return getDefaultDataForType(type, nodeId)
if (desc.getResetData) return { ...desc.getResetData(nodeId) }
return getDefaultDataForType(type, nodeId)
}
/** Id prefix for this type (e.g. cfg_, rnd_). Used by getNextNodeId. */
export function getIdPrefix(type: string): string {
return getNodeType(type)?.idPrefix ?? 'node_'
}
/** Default style for this type. */
export function getDefaultStyle(type: string): { width: number; height: number } {
const desc = getNodeType(type)
if (desc) return desc.defaultStyle
return { width: 320, height: 320 }
}
/** Whether a connection from source to target is allowed based on registered types. */
export function isConnectionAllowed(
sourceType: string,
targetType: string,
sourceNodeId: string,
targetNodeId: string
): boolean {
if (sourceNodeId === targetNodeId) return false
const sourceDesc = getNodeType(sourceType)
const targetDesc = getNodeType(targetType)
if (!sourceDesc || !targetDesc) return false
if (!targetDesc.hasInput) return false
if (!sourceDesc.hasOutput) return false
if (targetDesc.allowedSourceTypes != null && !targetDesc.allowedSourceTypes.includes(sourceType)) return false
if (sourceDesc.allowedTargetTypes != null && !sourceDesc.allowedTargetTypes.includes(targetType)) return false
return true
}
/** Edge label when target is of this type (for AnimatedEdge). */
export function getConnectionLabelForTarget(targetType: string): string | undefined {
return getNodeType(targetType)?.connectionLabel
}
/** Help entry for a node type. Use in NodeHelpPopover. */
export function getNodeHelp(nodeType: string): NodeHelpEntry {
const desc = getNodeType(nodeType)
return desc?.help ?? { title: nodeType, content: null }
}

View File

@@ -0,0 +1,13 @@
/**
* Central node and edge types for the app. Use AppNode / AppEdge in graph state and context.
*/
import type { Node, Edge } from '@xyflow/react'
import type { ConfigNodeData } from '@/components/nodes/ConfigNode'
import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
import type { VariableNodeData } from '@/components/nodes/VariableNode'
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData
export type AppNode = Node<AppNodeData>
export type AppEdge = Edge

View File

@@ -0,0 +1,92 @@
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
import type { EditorState } from '@codemirror/state'
const NUNJUCKS_KEYWORDS = [
'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not',
'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock',
'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw',
]
const NUNJUCKS_FILTERS = [
'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace',
'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby',
'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice',
]
/** Get line text (CodeMirror 6: doc.line(n) is 1-based) */
function getLineText(state: EditorState, lineNo0Based: number): string {
return state.doc.line(lineNo0Based + 1).text
}
/** Detect if position is inside {{ or {% from the start of the line */
function insideNunjucks(state: EditorState, lineNo0Based: number, posInLine: number): boolean {
const line = getLineText(state, lineNo0Based)
const before = line.slice(0, posInLine)
const openVar = before.lastIndexOf('{{')
const openTag = before.lastIndexOf('{%')
const closeVar = before.lastIndexOf('}}')
const closeTag = before.lastIndexOf('%}')
if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true
if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true
return false
}
/** Get the word fragment before the cursor for matching */
function wordBefore(state: EditorState, lineNo0Based: number, posInLine: number): string {
const line = getLineText(state, lineNo0Based)
let start = posInLine
while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1
return line.slice(start, posInLine)
}
export function nunjucksCompletionSource(
variableIds: string[],
configTitles?: string[],
functionIds?: string[],
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext) => {
const { state, pos } = context
const line = state.doc.lineAt(pos)
if (!insideNunjucks(state, line.number - 1, pos - line.from)) return null
const word = wordBefore(state, line.number - 1, pos - line.from)
const from = pos - word.length
const options: { label: string; type?: string; info?: string }[] = []
for (const id of variableIds) {
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: id, type: 'variable', info: 'Variable' })
}
}
for (const id of functionIds ?? []) {
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" })
}
}
for (const kw of NUNJUCKS_KEYWORDS) {
if (!word || kw.startsWith(word.toLowerCase())) {
options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' })
}
}
for (const f of NUNJUCKS_FILTERS) {
if (!word || f.startsWith(word.toLowerCase())) {
options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` })
}
}
if (configTitles?.length) {
for (const t of configTitles) {
if (!word || t.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: t, type: 'variable', info: 'Config' })
}
}
}
if (options.length === 0) return null
return {
from,
options: options.slice(0, 50),
validFor: /^[\w.-]*$/,
}
}
}

View File

@@ -0,0 +1,79 @@
import { StreamLanguage } from '@codemirror/language'
/** Nunjucks block comment {# ... #} */
function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
if (stream.match(/^\{#/)) {
while (!stream.eol()) {
if (stream.match(/#\}/)) return 'comment'
stream.next()
}
return 'comment'
}
return null
}
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
function tokenNunjucksBlock(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
if (stream.match(/^\{\{/)) {
while (!stream.eol()) {
if (stream.match(/\}\}/)) return 'variableName.special'
stream.next()
}
return 'variableName.special'
}
if (stream.match(/^\{\%/)) {
while (!stream.eol()) {
if (stream.match(/%\}/)) return 'keyword'
stream.next()
}
return 'keyword'
}
return null
}
/** Simple PlantUML + Nunjucks stream parser for syntax highlighting in CodeMirror */
const plantumlParser = StreamLanguage.define({
name: 'plantuml',
token(stream) {
// Nunjucks {# ... #} comment
const nunjucksComment = tokenNunjucksComment(stream)
if (nunjucksComment) return nunjucksComment
// Nunjucks {{ }} and {% %}
const nunjucksBlock = tokenNunjucksBlock(stream)
if (nunjucksBlock) return nunjucksBlock
// 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

@@ -0,0 +1,93 @@
/**
* Registers built-in node types (config, render, variable, function).
* Import this once at app startup (e.g. in main.tsx) so the registry is populated.
*/
import React from 'react'
import { ScrollText, Sparkles, Variable, Code2 } from 'lucide-react'
import { registerNodeType } from './nodeRegistry'
import { NODE_HELP } from './nodeHelp'
import ConfigNode from '../components/nodes/ConfigNode'
import RenderingNode from '../components/nodes/RenderingNode'
import VariableNode from '../components/nodes/VariableNode'
import FunctionNode from '../components/nodes/FunctionNode'
const ICON_CLASS = 'mr-2 h-4 w-4'
export function registerBuiltinNodes(): void {
registerNodeType({
id: 'config',
component: ConfigNode,
defaultStyle: { width: 320, height: 320 },
defaultData: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' },
idPrefix: 'cfg_',
hasInput: true,
hasOutput: true,
allowedSourceTypes: ['config', 'variable', 'function'],
allowedTargetTypes: ['config', 'render'],
help: NODE_HELP.config,
menuLabel: 'Config',
menuIcon: <ScrollText className={ICON_CLASS} />,
getDefaultData: (newId) => ({
configType: 'plantuml',
content: '@startuml\n\n@enduml\n',
title: newId ?? '',
}),
getResetData: (nodeId) => ({
configType: 'plantuml',
content: '@startuml\n\n@enduml\n',
title: nodeId ?? '',
}),
connectionLabel: 'adding input',
})
registerNodeType({
id: 'render',
component: RenderingNode,
defaultStyle: { width: 384, height: 320 },
defaultData: { viewportWidth: 1200, viewportHeight: 800 },
idPrefix: 'rnd_',
hasInput: true,
hasOutput: false,
allowedSourceTypes: ['config'],
help: NODE_HELP.render,
menuLabel: 'Renderer',
menuIcon: <Sparkles className={ICON_CLASS} />,
connectionLabel: 'rendering',
})
registerNodeType({
id: 'variable',
component: VariableNode,
defaultStyle: { width: 224, height: 180 },
defaultData: { value: '', valueType: 'string' },
idPrefix: 'var_',
hasInput: false,
hasOutput: true,
allowedTargetTypes: ['config', 'function'],
help: NODE_HELP.variable,
menuLabel: 'Variable',
menuIcon: <Variable className={ICON_CLASS} />,
})
registerNodeType({
id: 'function',
component: FunctionNode,
defaultStyle: { width: 288, height: 260 },
defaultData: {
body: `function(num, kwargs) {
return num + (kwargs.bar || 0);
}`,
},
idPrefix: 'fn_',
hasInput: true,
hasOutput: true,
allowedSourceTypes: ['config', 'variable', 'function'],
allowedTargetTypes: ['config', 'function'],
help: NODE_HELP.function,
menuLabel: 'Function',
menuIcon: <Code2 className={ICON_CLASS} />,
getResetData: () => ({ body: '' }),
connectionLabel: 'adding input',
})
}

View File

@@ -0,0 +1,83 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
const STORAGE_KEY = 'zui-theme'
export type Theme = 'light' | 'dark'
function readStored(): Theme | null {
try {
const s = localStorage.getItem(STORAGE_KEY)
if (s === 'light' || s === 'dark') return s
} catch (_) {}
return null
}
function systemPrefersDark(): boolean {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
} catch (_) {
return false
}
}
type ThemeContextValue = {
theme: Theme
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = readStored()
if (stored) return stored
return systemPrefersDark() ? 'dark' : 'light'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}, [theme])
useEffect(() => {
const m = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
if (readStored() != null) return
setThemeState(m.matches ? 'dark' : 'light')
}
m.addEventListener('change', handler)
return () => m.removeEventListener('change', handler)
}, [])
const setTheme = useCallback((next: Theme) => {
setThemeState(next)
try {
localStorage.setItem(STORAGE_KEY, next)
} catch (_) {}
}, [])
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === 'light' ? 'dark' : 'light'
try {
localStorage.setItem(STORAGE_KEY, next)
} catch (_) {}
return next
})
}, [])
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme])
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}