variables and templates

This commit is contained in:
2026-03-07 15:01:00 +01:00
parent 6572052dba
commit 72faf32011
6 changed files with 227 additions and 10 deletions

47
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"nunjucks": "^3.2.4",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"reactflow": "^11.0.0", "reactflow": "^11.0.0",
@@ -3397,6 +3398,12 @@
"d3-zoom": "^3.0.0" "d3-zoom": "^3.0.0"
} }
}, },
"node_modules/a-sync-waterfall": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz",
"integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==",
"license": "MIT"
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -3551,6 +3558,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/ast-types": { "node_modules/ast-types": {
"version": "0.16.1", "version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -6102,6 +6115,40 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/nunjucks": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz",
"integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==",
"license": "BSD-2-Clause",
"dependencies": {
"a-sync-waterfall": "^1.0.0",
"asap": "^2.0.3",
"commander": "^5.1.0"
},
"bin": {
"nunjucks-precompile": "bin/precompile"
},
"engines": {
"node": ">= 6.9.0"
},
"peerDependencies": {
"chokidar": "^3.3.0"
},
"peerDependenciesMeta": {
"chokidar": {
"optional": true
}
}
},
"node_modules/nunjucks/node_modules/commander": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@@ -9,17 +9,18 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@uiw/react-codemirror": "^4.25.7",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@uiw/react-codemirror": "^4.25.7",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"nunjucks": "^3.2.4",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"reactflow": "^11.0.0", "reactflow": "^11.0.0",

View File

@@ -1,7 +1,9 @@
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { useResizeHeight } from '../../hooks/useResizeHeight' import { useResizeHeight } from '../../hooks/useResizeHeight'
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
import { plantumlLanguage } from '../../lib/plantumlLanguage' import { plantumlLanguage } from '../../lib/plantumlLanguage'
import { useTheme } from '../../lib/themeContext' import { useTheme } from '../../lib/themeContext'
import { import {
@@ -116,7 +118,22 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
[insertAt] [insertAt]
) )
const extensions = useMemo(() => [plantumlLanguage.extension], []) const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
const configTitles = useMemo(
() => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id),
[connectedConfigNodes],
)
const extensions = useMemo(
() => [
plantumlLanguage.extension,
autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
activateOnTyping: true,
}),
],
[variableIds, functionIds, configTitles],
)
const [editorHeight, editorContainerRef] = useResizeHeight(180) const [editorHeight, editorContainerRef] = useResizeHeight(180)
const dimensions = const dimensions =

View File

@@ -1,4 +1,5 @@
import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react' import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { useTheme } from '../../lib/themeContext' import { useTheme } from '../../lib/themeContext'
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
@@ -188,18 +189,45 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId) if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId)
const resolvedPlantuml = resolveIncludes(plantumlText) const resolvedIncludes = resolveIncludes(plantumlText)
const varMap: Record<string, string> = {} const varMap: Record<string, string> = {}
// Use null prototype so keys like "var", "constructor", "toString" don't collide with Object.prototype
const nunjucksContext = Object.create(null) as Record<string, unknown>
for (const e of edges) { for (const e of edges) {
if (configIdsUsed.has(e.target)) { if (!configIdsUsed.has(e.target)) continue
const src = nodes.find((n: any) => n.id === e.source) const src = nodes.find((n: any) => n.id === e.source)
if (src?.type === 'variable') { if (src?.type === 'variable') {
const v = src.data?.value const v = src.data?.value
varMap[src.id] = v === undefined || v === null ? '' : String(v) const str = v === undefined || v === null ? '' : String(v)
varMap[src.id] = str
// 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') {
const body = src.data?.body ?? 'return args[0];'
nunjucksContext[src.id] = (...args: unknown[]) => {
try {
const fn = new Function('args', body)
const result = fn(args)
if (result === undefined || result === null) return ''
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return result
return String(result)
} catch {
return ''
} }
} }
} }
}
let afterNunjucks: string
try {
const env = new nunjucks.Environment([], { autoescape: false })
afterNunjucks = env.renderString(resolvedIncludes, nunjucksContext)
} catch (nunjucksErr: any) {
throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`)
}
const resolveFunctionCalls = (text: string): string => { const resolveFunctionCalls = (text: string): string => {
const fnCallRegex = /\$\{([\w-]+)\s*\(([^)]*)\)\}/g const fnCallRegex = /\$\{([\w-]+)\s*\(([^)]*)\)\}/g
@@ -224,7 +252,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
const resolveVariables = (text: string): string => const resolveVariables = (text: string): string =>
text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '') text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '')
const afterFunctions = resolveFunctionCalls(resolvedPlantuml) const afterFunctions = resolveFunctionCalls(afterNunjucks)
let resolvedWithVars = resolveVariables(afterFunctions) let resolvedWithVars = resolveVariables(afterFunctions)
if (theme === 'dark') { if (theme === 'dark') {
resolvedWithVars = resolvedWithVars.replace( resolvedWithVars = resolvedWithVars.replace(

View File

@@ -0,0 +1,86 @@
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
const NUNJUCKS_KEYWORDS = [
'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not',
'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock',
'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw',
]
const NUNJUCKS_FILTERS = [
'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace',
'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby',
'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice',
]
/** Detect if position is inside {{ or {% from the start of the line */
function insideNunjucks(doc: { getLine: (n: number) => string }, lineNo: number, pos: number): boolean {
const line = doc.getLine(lineNo)
const before = line.slice(0, pos)
const openVar = before.lastIndexOf('{{')
const openTag = before.lastIndexOf('{%')
const closeVar = before.lastIndexOf('}}')
const closeTag = before.lastIndexOf('%}')
if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true
if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true
return false
}
/** Get the word fragment before the cursor for matching */
function wordBefore(doc: { getLine: (n: number) => string }, lineNo: number, pos: number): string {
const line = doc.getLine(lineNo)
let start = pos
while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1
return line.slice(start, pos)
}
export function nunjucksCompletionSource(
variableIds: string[],
configTitles?: string[],
functionIds?: string[],
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext) => {
const { state, pos } = context
const line = state.doc.lineAt(pos)
if (!insideNunjucks(state.doc, line.number - 1, pos - line.from)) return null
const word = wordBefore(state.doc, line.number - 1, pos - line.from)
const from = pos - word.length
const options: { label: string; type?: string; info?: string }[] = []
for (const id of variableIds) {
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: id, type: 'variable', info: 'Variable' })
}
}
for (const id of functionIds ?? []) {
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: id, type: 'function', info: 'Function (e.g. ' + id + '(var, 4))' })
}
}
for (const kw of NUNJUCKS_KEYWORDS) {
if (!word || kw.startsWith(word.toLowerCase())) {
options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' })
}
}
for (const f of NUNJUCKS_FILTERS) {
if (!word || f.startsWith(word.toLowerCase())) {
options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` })
}
}
if (configTitles?.length) {
for (const t of configTitles) {
if (!word || t.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: t, type: 'variable', info: 'Config' })
}
}
}
if (options.length === 0) return null
return {
from,
options: options.slice(0, 50),
validFor: /^[\w.-]*$/,
}
}
}

View File

@@ -1,9 +1,47 @@
import { StreamLanguage } from '@codemirror/language' import { StreamLanguage } from '@codemirror/language'
/** Simple PlantUML stream parser for syntax highlighting in CodeMirror */ /** Nunjucks block comment {# ... #} */
function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
if (stream.match(/^\{#/)) {
while (!stream.eol()) {
if (stream.match(/#\}/)) return 'comment'
stream.next()
}
return 'comment'
}
return null
}
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
function tokenNunjucksBlock(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
if (stream.match(/^\{\{/)) {
while (!stream.eol()) {
if (stream.match(/\}\}/)) return 'variableName.special'
stream.next()
}
return 'variableName.special'
}
if (stream.match(/^\{\%/)) {
while (!stream.eol()) {
if (stream.match(/%\}/)) return 'keyword'
stream.next()
}
return 'keyword'
}
return null
}
/** Simple PlantUML + Nunjucks stream parser for syntax highlighting in CodeMirror */
const plantumlParser = StreamLanguage.define({ const plantumlParser = StreamLanguage.define({
name: 'plantuml', name: 'plantuml',
token(stream) { token(stream) {
// Nunjucks {# ... #} comment
const nunjucksComment = tokenNunjucksComment(stream)
if (nunjucksComment) return nunjucksComment
// Nunjucks {{ }} and {% %}
const nunjucksBlock = tokenNunjucksBlock(stream)
if (nunjucksBlock) return nunjucksBlock
// Single-quote line comment (PlantUML) // Single-quote line comment (PlantUML)
if (stream.match(/^'/)) { if (stream.match(/^'/)) {
stream.skipToEnd() stream.skipToEnd()