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)}`)
}