From 65d4f216a8ce57c98b82bfe24b9a18a7dfe34e8d Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 7 Mar 2026 20:36:24 +0100 Subject: [PATCH] modular options --- src/components/graph/ConfigNode.tsx | 35 ++++++--- src/components/graph/RenderingNode.tsx | 102 +++++++++++++++---------- 2 files changed, 84 insertions(+), 53 deletions(-) diff --git a/src/components/graph/ConfigNode.tsx b/src/components/graph/ConfigNode.tsx index 233d947..09914e6 100644 --- a/src/components/graph/ConfigNode.tsx +++ b/src/components/graph/ConfigNode.tsx @@ -96,10 +96,23 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: [onChange, value] ) + const insertExtendsFromNode = useCallback( + (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { + insertAt(`{% extends "${sourceNode.id}" %}\n`, mode) + }, + [insertAt] + ) + const insertIncludeFromNode = useCallback( (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { - const ref = `${sourceNode.id}.puml` - insertAt(`!include ${ref}\n`, mode) + insertAt(`{% include "${sourceNode.id}" %}\n`, mode) + }, + [insertAt] + ) + + const insertImportFromNode = useCallback( + (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { + insertAt(`{% import "${sourceNode.id}" as ${sourceNode.id} %}\n`, mode) }, [insertAt] ) @@ -162,21 +175,21 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: insertIncludeFromNode(n, 'prepend')} + onClick={() => insertExtendsFromNode(n, 'cursor')} > - Prepend include - - insertIncludeFromNode(n, 'append')} - > - Append include + extend insertIncludeFromNode(n, 'cursor')} > - Insert at cursor + include + + insertImportFromNode(n, 'cursor')} + > + import diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx index c254e02..9d4092b 100644 --- a/src/components/graph/RenderingNode.tsx +++ b/src/components/graph/RenderingNode.tsx @@ -147,52 +147,71 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: try { const configIdsUsed = new Set() - const resolveIncludes = (text: string, visited = new Set()): string => { - const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm - return text.replace(includeRegex, (match: string, indent: string, ref: string) => { - const refName = ref.endsWith('.puml') ? ref.slice(0, -5) : ref - 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}`) - - const isReachable = (startId: string, targetId: string) => { - const q: string[] = [startId] - const seen = new Set([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) - } - } + const isReachable = (startId: string, targetId: string) => { + const q: string[] = [startId] + const seen = new Set([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) - if (refNode.type === 'config') configIdsUsed.add(refNode.id) - const includedRaw = String(refNode.data?.plantuml ?? '') - const resolved = resolveIncludes(includedRaw, visited) - visited.delete(refNode.id) - - const indented = resolved - .split('\n') - .map((line: string) => (line === '' ? '' : indent + line)) - .join('\n') - return indented - }) + } + return false } - if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId) + const resolveExtendsRef = (name: string): string => { + const refName = name.replace(/\.(puml|html)$/, '').trim() + return nodes.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName + } - const resolvedIncludes = resolveIncludes(plantumlText) + /** Collect refs from {% extends %}, {% include %}, {% import %} in template content */ + const getTemplateRefs = (content: string): string[] => { + const refs: string[] = [] + const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/) + if (extendMatch) refs.push(extendMatch[1].trim()) + const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g + let m + while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim()) + const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g + while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim()) + return refs + } + + const addConfigAndRefs = (templateName: string, visited = new Set()) => { + const refId = resolveExtendsRef(templateName) + if (visited.has(refId)) throw new Error(`Circular reference detected: ${templateName}`) + const node = nodes.find((n: any) => n.id === refId && n.type === 'config') + if (!node) throw new Error(`Config not found: ${templateName}`) + if (refId !== srcId && !isReachable(refId, id)) + throw new Error(`Referenced config not connected to renderer: ${templateName}`) + visited.add(refId) + configIdsUsed.add(refId) + const content = String(node.data?.plantuml ?? '') + for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited) + } + + if (srcId && srcNode?.type === 'config') addConfigAndRefs(srcId) + + // Loader for Nunjucks {% extends %}, {% include %}, {% import %}: resolve template name to config node's plantuml + const configLoader = { + getSource: (name: string): { src: string; path: string } | null => { + const refId = resolveExtendsRef(name) + const node = nodes.find((n: any) => n.id === refId && n.type === 'config') + if (!node) return null + if (refId !== srcId && !isReachable(refId, id)) + throw new Error(`Referenced config not connected to renderer: ${name}`) + return { + src: String(node.data?.plantuml ?? ''), + path: name, + } + }, + } // Use null prototype so keys like "var", "constructor", "toString" don't collide with Object.prototype. - // Only Nunjucks {{ var }} / {% if var %} are supported; context is built from connected variable/function nodes. const nunjucksContext = Object.create(null) as Record for (const e of edges) { @@ -201,7 +220,6 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: if (src?.type === 'variable') { const v = src.data?.value const str = v === undefined || v === null ? '' : String(v) - // Keep booleans/numbers for {% if %} etc.; Nunjucks treats "false" as truthy nunjucksContext[src.id] = v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str } else if (src?.type === 'function') { @@ -222,8 +240,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: let afterNunjucks: string try { - const env = new nunjucks.Environment([], { autoescape: false }) - afterNunjucks = env.renderString(resolvedIncludes, nunjucksContext) + const env = new nunjucks.Environment([configLoader], { autoescape: false }) + afterNunjucks = env.render(srcId!, nunjucksContext) } catch (nunjucksErr: any) { throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`) }