modular options

This commit is contained in:
2026-03-07 20:36:24 +01:00
parent 5aa01f4dec
commit 65d4f216a8
2 changed files with 84 additions and 53 deletions

View File

@@ -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>

View File

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