modular options
This commit is contained in:
@@ -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 }:
|
||||
<MenubarSubContent>
|
||||
<MenubarItem
|
||||
className="text-xs"
|
||||
onClick={() => insertIncludeFromNode(n, 'prepend')}
|
||||
onClick={() => insertExtendsFromNode(n, 'cursor')}
|
||||
>
|
||||
Prepend include
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="text-xs"
|
||||
onClick={() => insertIncludeFromNode(n, 'append')}
|
||||
>
|
||||
Append include
|
||||
extend
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="text-xs"
|
||||
onClick={() => insertIncludeFromNode(n, 'cursor')}
|
||||
>
|
||||
Insert at cursor
|
||||
include
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="text-xs"
|
||||
onClick={() => insertImportFromNode(n, 'cursor')}
|
||||
>
|
||||
import
|
||||
</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
|
||||
@@ -147,52 +147,71 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
||||
try {
|
||||
const configIdsUsed = new Set<string>()
|
||||
|
||||
const resolveIncludes = (text: string, visited = new Set<string>()): 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<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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
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<string>()) => {
|
||||
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<string, unknown>
|
||||
|
||||
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)}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user