modular options
This commit is contained in:
@@ -96,10 +96,23 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
[onChange, value]
|
[onChange, value]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const insertExtendsFromNode = useCallback(
|
||||||
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||||
|
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
||||||
|
},
|
||||||
|
[insertAt]
|
||||||
|
)
|
||||||
|
|
||||||
const insertIncludeFromNode = useCallback(
|
const insertIncludeFromNode = useCallback(
|
||||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||||
const ref = `${sourceNode.id}.puml`
|
insertAt(`{% include "${sourceNode.id}" %}\n`, mode)
|
||||||
insertAt(`!include ${ref}\n`, mode)
|
},
|
||||||
|
[insertAt]
|
||||||
|
)
|
||||||
|
|
||||||
|
const insertImportFromNode = useCallback(
|
||||||
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||||
|
insertAt(`{% import "${sourceNode.id}" as ${sourceNode.id} %}\n`, mode)
|
||||||
},
|
},
|
||||||
[insertAt]
|
[insertAt]
|
||||||
)
|
)
|
||||||
@@ -162,21 +175,21 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
|
|||||||
<MenubarSubContent>
|
<MenubarSubContent>
|
||||||
<MenubarItem
|
<MenubarItem
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
onClick={() => insertIncludeFromNode(n, 'prepend')}
|
onClick={() => insertExtendsFromNode(n, 'cursor')}
|
||||||
>
|
>
|
||||||
Prepend include
|
extend
|
||||||
</MenubarItem>
|
|
||||||
<MenubarItem
|
|
||||||
className="text-xs"
|
|
||||||
onClick={() => insertIncludeFromNode(n, 'append')}
|
|
||||||
>
|
|
||||||
Append include
|
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarItem
|
<MenubarItem
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
onClick={() => insertIncludeFromNode(n, 'cursor')}
|
onClick={() => insertIncludeFromNode(n, 'cursor')}
|
||||||
>
|
>
|
||||||
Insert at cursor
|
include
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => insertImportFromNode(n, 'cursor')}
|
||||||
|
>
|
||||||
|
import
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
</MenubarSub>
|
</MenubarSub>
|
||||||
|
|||||||
@@ -147,52 +147,71 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
try {
|
try {
|
||||||
const configIdsUsed = new Set<string>()
|
const configIdsUsed = new Set<string>()
|
||||||
|
|
||||||
const resolveIncludes = (text: string, visited = new Set<string>()): string => {
|
const isReachable = (startId: string, targetId: string) => {
|
||||||
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
|
const q: string[] = [startId]
|
||||||
return text.replace(includeRegex, (match: string, indent: string, ref: string) => {
|
const seen = new Set<string>([startId])
|
||||||
const refName = ref.endsWith('.puml') ? ref.slice(0, -5) : ref
|
while (q.length) {
|
||||||
const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName)
|
const cur = q.shift()!
|
||||||
if (!refNode) throw new Error(`Included node not found: ${ref}`)
|
if (cur === targetId) return true
|
||||||
if (visited.has(refNode.id)) throw new Error(`Circular include detected: ${ref}`)
|
for (const e of edges) {
|
||||||
|
if (e.source === cur && !seen.has(e.target)) {
|
||||||
const isReachable = (startId: string, targetId: string) => {
|
seen.add(e.target)
|
||||||
const q: string[] = [startId]
|
q.push(e.target)
|
||||||
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}`)
|
return false
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
// 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>
|
const nunjucksContext = Object.create(null) as Record<string, unknown>
|
||||||
|
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
@@ -201,7 +220,6 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
if (src?.type === 'variable') {
|
if (src?.type === 'variable') {
|
||||||
const v = src.data?.value
|
const v = src.data?.value
|
||||||
const str = v === undefined || v === null ? '' : String(v)
|
const str = v === undefined || v === null ? '' : String(v)
|
||||||
// Keep booleans/numbers for {% if %} etc.; Nunjucks treats "false" as truthy
|
|
||||||
nunjucksContext[src.id] =
|
nunjucksContext[src.id] =
|
||||||
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
||||||
} else if (src?.type === 'function') {
|
} else if (src?.type === 'function') {
|
||||||
@@ -222,8 +240,8 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
|
|||||||
|
|
||||||
let afterNunjucks: string
|
let afterNunjucks: string
|
||||||
try {
|
try {
|
||||||
const env = new nunjucks.Environment([], { autoescape: false })
|
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||||
afterNunjucks = env.renderString(resolvedIncludes, nunjucksContext)
|
afterNunjucks = env.render(srcId!, nunjucksContext)
|
||||||
} catch (nunjucksErr: any) {
|
} catch (nunjucksErr: any) {
|
||||||
throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`)
|
throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user