Add yaml compositino

This commit is contained in:
2026-03-05 23:57:46 +01:00
parent 8e5147cfd1
commit 48b9d29114
3 changed files with 194 additions and 3 deletions

View File

@@ -17,6 +17,12 @@ export default function ConfigNode({ id, data }: Props) {
const ctx = React.useContext(FlowContext)
const setNodes = ctx?.setNodes
const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? ''
const editorRef = React.useRef<any>(null)
const monacoRef = React.useRef<any>(null)
const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
React.useEffect(() => {
// keep node data in sync on mount
@@ -40,6 +46,44 @@ export default function ConfigNode({ id, data }: Props) {
[id, setNodes]
)
const handleEditorMount = React.useCallback((editor: any, monaco: any) => {
editorRef.current = editor
monacoRef.current = monaco
}, [])
const insertIncludeFromNode = React.useCallback(
(sourceNode: any) => {
const ref = `${sourceNode.id}.yaml`
const includeText = `!include ${ref}\n`
const editor = editorRef.current
const monaco = monacoRef.current
try {
if (editor && monaco) {
const selection = editor.getSelection()
let range
if (selection) {
range = new monaco.Range(selection.startLineNumber, selection.startColumn, selection.startLineNumber, selection.startColumn)
} else {
const model = editor.getModel()
const lineCount = model.getLineCount()
range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1)
}
editor.executeEdits('insert-include', [{ range, text: includeText, forceMoveMarkers: true }])
const newVal = editor.getModel().getValue()
onChange(newVal)
return
}
onChange(value + '\n' + includeText)
} catch (err) {
// eslint-disable-next-line no-console
console.error('Insert include failed', err)
}
},
[onChange, value]
)
return (
<div className="w-80 bg-white rounded shadow hover:shadow-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
@@ -47,12 +91,34 @@ export default function ConfigNode({ id, data }: Props) {
<div className="text-xs text-gray-400">YAML</div>
</div>
{connectedConfigNodes.length > 0 && (
<div className="px-3 py-2 text-xs bg-gray-50 border-b border-gray-100">
<div className="text-xs font-medium text-gray-600">Connected configs</div>
<div className="mt-1 space-y-1">
{connectedConfigNodes.map((n: any) => (
<div key={n.id} className="flex items-center justify-between">
<div className="text-xs text-gray-700">{n.data?.title ?? n.id}</div>
<div>
<button
className="text-xs px-2 py-0.5 bg-white border rounded text-gray-600"
onClick={() => insertIncludeFromNode(n)}
>
Insert include
</button>
</div>
</div>
))}
</div>
</div>
)}
<div style={{ height: 180 }}>
<Editor
height="100%"
defaultLanguage="yaml"
value={value}
theme="vs-light"
onMount={handleEditorMount}
onChange={onChange}
options={{ minimap: { enabled: false }, fontSize: 12 }}
/>

View File

@@ -36,11 +36,61 @@ export default function RenderingNode({ id }: Props) {
}
try {
const parsed = yaml.load(yamlText)
// resolve !include directives by inlining YAML from reachable config nodes
const resolveIncludes = (text: string, visited = new Set<string>()): string => {
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
return text.replace(includeRegex, (match, indent, ref) => {
const refName = ref.endsWith('.yaml') ? ref.slice(0, -5) : ref
// try to find a node by id or by title
const refNode = nodes.find((n: any) => n.id === refName || (n.data?.title === refName))
if (!refNode) {
throw new Error(`Included node not found: ${ref}`)
}
if (visited.has(refNode.id)) {
throw new Error(`Circular include detected: ${ref}`)
}
// ensure refNode can reach this rendering node
const isReachable = (startId: string, targetId: string) => {
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
}
if (!isReachable(refNode.id, id)) {
throw new Error(`Included node not connected to renderer: ${ref}`)
}
visited.add(refNode.id)
const includedRaw = String(refNode.data?.yaml ?? '')
const resolved = resolveIncludes(includedRaw, visited)
visited.delete(refNode.id)
// indent included content to match include position
const indented = resolved
.split('\n')
.map((line: string, idx: number) => (line === '' ? '' : indent + line))
.join('\n')
return indented
})
}
const resolvedYaml = resolveIncludes(yamlText)
const parsed = yaml.load(resolvedYaml)
const newOutput = JSON.stringify(parsed, null, 2)
setOutput((prev) => (prev === newOutput ? prev : newOutput))
} catch (err: any) {
const msg = 'YAML parse error: ' + err.message
const msg = 'YAML parse/include error: ' + err.message
setOutput((prev) => (prev === msg ? prev : msg))
}
}, [id, incomingIds.join(','), yamlText, nodes.length, edges.length])